Просмотр исходного кода

Merge pull request #4770 from netbox-community/3703-limit-tag-creation

Closes #3703: Restrict tag creation
Jeremy Stretch 5 лет назад
Родитель
Сommit
2d4694e72d
37 измененных файлов с 349 добавлено и 250 удалено
  1. 0 4
      base_requirements.txt
  2. 15 0
      docs/release-notes/version-2.9.md
  3. 3 5
      netbox/circuits/api/serializers.py
  4. 5 3
      netbox/circuits/forms.py
  5. 2 3
      netbox/circuits/tests/test_views.py
  6. 19 41
      netbox/dcim/api/serializers.py
  7. 54 26
      netbox/dcim/forms.py
  8. 44 26
      netbox/dcim/tests/test_views.py
  9. 1 2
      netbox/extras/api/nested_serializers.py
  10. 22 0
      netbox/extras/api/serializers.py
  11. 20 12
      netbox/extras/forms.py
  12. 10 0
      netbox/extras/models/tags.py
  13. 1 1
      netbox/extras/tests/test_api.py
  14. 0 11
      netbox/extras/tests/test_changelog.py
  15. 25 14
      netbox/extras/tests/test_tags.py
  16. 8 10
      netbox/extras/tests/test_views.py
  17. 2 0
      netbox/extras/urls.py
  18. 19 14
      netbox/extras/views.py
  19. 7 13
      netbox/ipam/api/serializers.py
  20. 17 7
      netbox/ipam/forms.py
  21. 6 6
      netbox/ipam/tests/test_views.py
  22. 0 2
      netbox/netbox/settings.py
  23. 2 3
      netbox/secrets/api/serializers.py
  24. 3 2
      netbox/secrets/forms.py
  25. 1 1
      netbox/templates/extras/tag.html
  26. 6 0
      netbox/templates/inc/nav_menu.html
  27. 2 3
      netbox/tenancy/api/serializers.py
  28. 3 2
      netbox/tenancy/forms.py
  29. 1 1
      netbox/tenancy/tests/test_views.py
  30. 1 15
      netbox/utilities/custom_inspectors.py
  31. 3 0
      netbox/utilities/querysets.py
  32. 8 1
      netbox/utilities/testing/utils.py
  33. 17 2
      netbox/utilities/testing/views.py
  34. 5 8
      netbox/virtualization/api/serializers.py
  35. 11 7
      netbox/virtualization/forms.py
  36. 6 4
      netbox/virtualization/tests/test_views.py
  37. 0 1
      requirements.txt

+ 0 - 4
base_requirements.txt

@@ -42,10 +42,6 @@ django-tables2
 # https://github.com/alex/django-taggit
 django-taggit
 
-# A Django REST Framework serializer which represents tags
-# https://github.com/glemmaPaul/django-taggit-serializer
-django-taggit-serializer
-
 # A Django field for representing time zones
 # https://github.com/mfogel/django-timezone-field/
 django-timezone-field

+ 15 - 0
docs/release-notes/version-2.9.md

@@ -10,6 +10,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
 
 ### Enhancements
 
+* [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object
 * [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components
 * [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations
 
@@ -18,6 +19,20 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
 * If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
 * `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
 
+### REST API Changes
+
+* The count of `tagged_items` is no longer included when viewing the tags list when `brief` is passed.
+* The assignment of tags to an object is now achieved in the same manner as specifying any other related device. The `tags` field accepts a list of JSON objects each matching a desired tag. (Alternatively, a list of numeric primary keys corresponding to tags may be passed instead.) For example:
+
+```json
+"tags": [
+  {"name": "First Tag"},
+  {"name": "Second Tag"}
+]
+```
+
+* The `tags` field of an object now includes a more complete representation of each tag, rather than just its name.
+
 ### Other Changes
 
 * The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey.

+ 3 - 5
netbox/circuits/api/serializers.py

@@ -1,11 +1,11 @@
 from rest_framework import serializers
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 from circuits.choices import CircuitStatusChoices
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
 from dcim.api.serializers import ConnectedEndpointSerializer
 from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
 from .nested_serializers import *
@@ -15,8 +15,7 @@ from .nested_serializers import *
 # Providers
 #
 
-class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
-    tags = TagListSerializerField(required=False)
+class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     circuit_count = serializers.IntegerField(read_only=True)
 
     class Meta:
@@ -49,14 +48,13 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
 
 
-class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     provider = NestedProviderSerializer()
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     type = NestedCircuitTypeSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     termination_a = CircuitCircuitTerminationSerializer(read_only=True)
     termination_z = CircuitCircuitTerminationSerializer(read_only=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = Circuit

+ 5 - 3
netbox/circuits/forms.py

@@ -3,8 +3,8 @@ from django import forms
 from dcim.models import Region, Site
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
-    TagField,
 )
+from extras.models import Tag
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
@@ -23,7 +23,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
 class ProviderForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     comments = CommentField()
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -165,7 +166,8 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         queryset=CircuitType.objects.all()
     )
     comments = CommentField()
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 

+ 2 - 3
netbox/circuits/tests/test_views.py

@@ -26,7 +26,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'noc_contact': 'noc@example.com',
             'admin_contact': 'admin@example.com',
             'comments': 'Another provider',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -106,7 +106,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'commit_rate': 1000,
             'description': 'A new circuit',
             'comments': 'Some comments',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -124,5 +124,4 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'commit_rate': 2000,
             'description': 'New description',
             'comments': 'New comments',
-
         }

+ 19 - 41
netbox/dcim/api/serializers.py

@@ -2,7 +2,6 @@ from django.contrib.contenttypes.models import ContentType
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 from dcim.choices import *
 from dcim.constants import *
@@ -14,6 +13,7 @@ from dcim.models import (
     VirtualChassis,
 )
 from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.models import VLAN
 from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -67,12 +67,11 @@ class RegionSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug', 'parent', 'description', 'site_count']
 
 
-class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     status = ChoiceField(choices=SiteStatusChoices, required=False)
     region = NestedRegionSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     time_zone = TimeZoneField(required=False)
-    tags = TagListSerializerField(required=False)
     circuit_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
@@ -112,7 +111,7 @@ class RackRoleSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'color', 'description', 'rack_count']
 
 
-class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     site = NestedSiteSerializer()
     group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -121,7 +120,6 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
     type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
     width = ChoiceField(choices=RackWidthChoices, required=False)
     outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
-    tags = TagListSerializerField(required=False)
     device_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
 
@@ -161,11 +159,10 @@ class RackUnitSerializer(serializers.Serializer):
     device = NestedDeviceSerializer(read_only=True)
 
 
-class RackReservationSerializer(ValidatedModelSerializer):
+class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     rack = NestedRackSerializer()
     user = NestedUserSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = RackReservation
@@ -224,10 +221,9 @@ class ManufacturerSerializer(ValidatedModelSerializer):
         ]
 
 
-class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     manufacturer = NestedManufacturerSerializer()
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
-    tags = TagListSerializerField(required=False)
     device_count = serializers.IntegerField(read_only=True)
 
     class Meta:
@@ -363,7 +359,7 @@ class PlatformSerializer(ValidatedModelSerializer):
         ]
 
 
-class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_role = NestedDeviceRoleSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -378,7 +374,6 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
     parent_device = serializers.SerializerMethodField()
     cluster = NestedClusterSerializer(required=False, allow_null=True)
     virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = Device
@@ -434,7 +429,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
     method = serializers.DictField()
 
 
-class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
+class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(
         choices=ConsolePortTypeChoices,
@@ -442,7 +437,6 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
         required=False
     )
     cable = NestedCableSerializer(read_only=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = ConsoleServerPort
@@ -452,7 +446,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
         ]
 
 
-class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
+class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(
         choices=ConsolePortTypeChoices,
@@ -460,7 +454,6 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
         required=False
     )
     cable = NestedCableSerializer(read_only=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = ConsolePort
@@ -470,7 +463,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
         ]
 
 
-class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
+class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(
         choices=PowerOutletTypeChoices,
@@ -488,9 +481,6 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     cable = NestedCableSerializer(
         read_only=True
     )
-    tags = TagListSerializerField(
-        required=False
-    )
 
     class Meta:
         model = PowerOutlet
@@ -500,7 +490,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
         ]
 
 
-class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
+class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(
         choices=PowerPortTypeChoices,
@@ -508,7 +498,6 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
         required=False
     )
     cable = NestedCableSerializer(read_only=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = PowerPort
@@ -518,7 +507,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
         ]
 
 
-class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
+class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=InterfaceTypeChoices)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
@@ -531,7 +520,6 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
         many=True
     )
     cable = NestedCableSerializer(read_only=True)
-    tags = TagListSerializerField(required=False)
     count_ipaddresses = serializers.IntegerField(read_only=True)
 
     class Meta:
@@ -563,11 +551,10 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
         return super().validate(data)
 
 
-class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer):
+class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PortTypeChoices)
     cable = NestedCableSerializer(read_only=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = RearPort
@@ -585,22 +572,20 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name']
 
 
-class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer):
+class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PortTypeChoices)
     rear_port = FrontPortRearPortSerializer()
     cable = NestedCableSerializer(read_only=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = FrontPort
         fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags']
 
 
-class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
+class DeviceBaySerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     installed_device = NestedDeviceSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = DeviceBay
@@ -611,12 +596,11 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
 # Inventory items
 #
 
-class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
+class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     # Provide a default value to satisfy UniqueTogetherValidator
     parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
     manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = InventoryItem
@@ -630,7 +614,7 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
 # Cables
 #
 
-class CableSerializer(ValidatedModelSerializer):
+class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     termination_a_type = ContentTypeField(
         queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
     )
@@ -641,7 +625,6 @@ class CableSerializer(ValidatedModelSerializer):
     termination_b = serializers.SerializerMethodField(read_only=True)
     status = ChoiceField(choices=CableStatusChoices, required=False)
     length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = Cable
@@ -710,9 +693,8 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
 # Virtual chassis
 #
 
-class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
+class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     master = NestedDeviceSerializer()
-    tags = TagListSerializerField(required=False)
     member_count = serializers.IntegerField(read_only=True)
 
     class Meta:
@@ -724,14 +706,13 @@ class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
 # Power panels
 #
 
-class PowerPanelSerializer(ValidatedModelSerializer):
+class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     site = NestedSiteSerializer()
     rack_group = NestedRackGroupSerializer(
         required=False,
         allow_null=True,
         default=None
     )
-    tags = TagListSerializerField(required=False)
     powerfeed_count = serializers.IntegerField(read_only=True)
 
     class Meta:
@@ -739,7 +720,7 @@ class PowerPanelSerializer(ValidatedModelSerializer):
         fields = ['id', 'site', 'rack_group', 'name', 'tags', 'powerfeed_count']
 
 
-class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     power_panel = NestedPowerPanelSerializer()
     rack = NestedRackSerializer(
         required=False,
@@ -762,9 +743,6 @@ class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
         choices=PowerFeedPhaseChoices,
         default=PowerFeedPhaseChoices.PHASE_SINGLE
     )
-    tags = TagListSerializerField(
-        required=False
-    )
 
     class Meta:
         model = PowerFeed

+ 54 - 26
netbox/dcim/forms.py

@@ -14,8 +14,9 @@ from timezone_field import TimeZoneFormField
 from circuits.models import Circuit, Provider
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm,
-    LocalConfigContextFilterForm, TagField,
+    LocalConfigContextFilterForm,
 )
+from extras.models import Tag
 from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
 from ipam.models import IPAddress, VLAN
 from tenancy.forms import TenancyFilterForm, TenancyForm
@@ -225,7 +226,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     )
     slug = SlugField()
     comments = CommentField()
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -486,7 +488,8 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         required=False
     )
     comments = CommentField()
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -766,7 +769,8 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
         ),
         widget=StaticSelect2()
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -911,7 +915,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
         slug_source='model'
     )
     comments = CommentField()
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -1736,11 +1741,14 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         required=False
     )
     comments = CommentField()
-    tags = TagField(required=False)
     local_context_data = JSONField(
         required=False,
         label=''
     )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = Device
@@ -2229,7 +2237,8 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
 
 
 class ConsolePortForm(BootstrapMixin, forms.ModelForm):
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -2256,7 +2265,8 @@ class ConsolePortCreateForm(LabeledComponentForm):
         max_length=100,
         required=False
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -2312,7 +2322,8 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
 
 
 class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -2339,7 +2350,8 @@ class ConsoleServerPortCreateForm(LabeledComponentForm):
         max_length=100,
         required=False
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -2409,7 +2421,8 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
 
 
 class PowerPortForm(BootstrapMixin, forms.ModelForm):
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -2446,7 +2459,8 @@ class PowerPortCreateForm(LabeledComponentForm):
         max_length=100,
         required=False
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -2506,7 +2520,8 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
         queryset=PowerPort.objects.all(),
         required=False
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -2550,7 +2565,8 @@ class PowerOutletCreateForm(LabeledComponentForm):
         max_length=100,
         required=False
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -2709,7 +2725,8 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
             },
         )
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -2793,7 +2810,8 @@ class InterfaceCreateForm(InterfaceCommonForm, LabeledComponentForm):
         required=False,
         widget=StaticSelect2(),
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
     untagged_vlan = DynamicModelChoiceField(
@@ -3005,7 +3023,8 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
 
 
 class FrontPortForm(BootstrapMixin, forms.ModelForm):
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -3196,7 +3215,8 @@ class RearPortFilterForm(DeviceComponentFilterForm):
 
 
 class RearPortForm(BootstrapMixin, forms.ModelForm):
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -3299,7 +3319,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
 
 
 class DeviceBayForm(BootstrapMixin, forms.ModelForm):
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -3320,7 +3341,8 @@ class DeviceBayCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(
         label='Name'
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -3350,7 +3372,8 @@ class DeviceBayBulkCreateForm(
     form_from_model(DeviceBay, ['description', 'tags']),
     DeviceBulkAddComponentForm
 ):
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -3654,7 +3677,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
 
 
 class CableForm(BootstrapMixin, forms.ModelForm):
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -3983,7 +4007,8 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
         queryset=Manufacturer.objects.all(),
         required=False
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -4131,7 +4156,8 @@ class DeviceSelectionForm(forms.Form):
 
 
 class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -4321,7 +4347,8 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm):
         queryset=RackGroup.objects.all(),
         required=False
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -4445,7 +4472,8 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
         required=False
     )
     comments = CommentField()
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 

+ 44 - 26
netbox/dcim/tests/test_views.py

@@ -94,7 +94,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'contact_phone': '123-555-9999',
             'contact_email': 'hank@stricklandpropane.com',
             'comments': 'Test site',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -202,7 +202,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'user': user3.pk,
             'tenant': None,
             'description': 'Rack reservation',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -268,7 +268,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'outer_depth': 500,
             'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
             'comments': 'Some comments',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -359,7 +359,7 @@ class DeviceTypeTestCase(
             'is_full_depth': True,
             'subdevice_role': '',  # CharField
             'comments': 'Some comments',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.bulk_edit_data = {
@@ -967,7 +967,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'vc_position': None,
             'vc_priority': None,
             'comments': 'A new device',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
             'local_context_data': None,
         }
 
@@ -1001,12 +1001,14 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             ConsolePort(device=device, name='Console Port 3'),
         ])
 
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'device': device.pk,
             'name': 'Console Port X',
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'description': 'A console port',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_create_data = {
@@ -1016,7 +1018,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'label_pattern': 'Serial[3-5]',
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'description': 'A console port',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_edit_data = {
@@ -1045,12 +1047,14 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             ConsoleServerPort(device=device, name='Console Server Port 3'),
         ])
 
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'device': device.pk,
             'name': 'Console Server Port X',
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'description': 'A console server port',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_create_data = {
@@ -1058,7 +1062,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'name_pattern': 'Console Server Port [4-6]',
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'description': 'A console server port',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_edit_data = {
@@ -1087,6 +1091,8 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             PowerPort(device=device, name='Power Port 3'),
         ])
 
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'device': device.pk,
             'name': 'Power Port X',
@@ -1094,7 +1100,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'maximum_draw': 100,
             'allocated_draw': 50,
             'description': 'A power port',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_create_data = {
@@ -1104,7 +1110,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'maximum_draw': 100,
             'allocated_draw': 50,
             'description': 'A power port',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_edit_data = {
@@ -1141,6 +1147,8 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
             PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]),
         ])
 
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'device': device.pk,
             'name': 'Power Outlet X',
@@ -1148,7 +1156,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'power_port': powerports[1].pk,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'description': 'A power outlet',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_create_data = {
@@ -1158,7 +1166,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'power_port': powerports[1].pk,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'description': 'A power outlet',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_edit_data = {
@@ -1202,6 +1210,8 @@ class InterfaceTestCase(
         )
         VLAN.objects.bulk_create(vlans)
 
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'device': device.pk,
             'virtual_machine': None,
@@ -1216,7 +1226,7 @@ class InterfaceTestCase(
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'untagged_vlan': vlans[0].pk,
             'tagged_vlans': [v.pk for v in vlans[1:4]],
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_create_data = {
@@ -1232,7 +1242,7 @@ class InterfaceTestCase(
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'untagged_vlan': vlans[0].pk,
             'tagged_vlans': [v.pk for v in vlans[1:4]],
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_edit_data = {
@@ -1279,6 +1289,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
         ])
 
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'device': device.pk,
             'name': 'Front Port X',
@@ -1286,7 +1298,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'rear_port': rearports[3].pk,
             'rear_port_position': 1,
             'description': 'New description',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_create_data = {
@@ -1297,7 +1309,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
                 '{}:1'.format(rp.pk) for rp in rearports[3:6]
             ],
             'description': 'New description',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_edit_data = {
@@ -1326,13 +1338,15 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             RearPort(device=device, name='Rear Port 3'),
         ])
 
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'device': device.pk,
             'name': 'Rear Port X',
             'type': PortTypeChoices.TYPE_8P8C,
             'positions': 3,
             'description': 'A rear port',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_create_data = {
@@ -1341,7 +1355,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'type': PortTypeChoices.TYPE_8P8C,
             'positions': 3,
             'description': 'A rear port',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_edit_data = {
@@ -1373,18 +1387,20 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
             DeviceBay(device=device, name='Device Bay 3'),
         ])
 
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'device': device.pk,
             'name': 'Device Bay X',
             'description': 'A device bay',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_create_data = {
             'device': device.pk,
             'name_pattern': 'Device Bay [4-6]',
             'description': 'A device bay',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_edit_data = {
@@ -1413,6 +1429,8 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
             InventoryItem(device=device, name='Inventory Item 3'),
         ])
 
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'device': device.pk,
             'manufacturer': manufacturer.pk,
@@ -1423,7 +1441,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'serial': '123ABC',
             'asset_tag': 'ABC123',
             'description': 'An inventory item',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_create_data = {
@@ -1435,7 +1453,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'part_id': '123456',
             'serial': '123ABC',
             'description': 'An inventory item',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_edit_data = {
@@ -1513,7 +1531,7 @@ class CableTestCase(
             'color': 'c0c0c0',
             'length': 100,
             'length_unit': CableLengthUnitChoices.UNIT_FOOT,
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -1626,7 +1644,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'site': sites[1].pk,
             'rack_group': rackgroups[1].pk,
             'name': 'Power Panel X',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -1680,7 +1698,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'amperage': 100,
             'max_utilization': 50,
             'comments': 'New comments',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
 
             # Connection
             'cable': None,

+ 1 - 2
netbox/extras/api/nested_serializers.py

@@ -38,11 +38,10 @@ class NestedGraphSerializer(WritableNestedSerializer):
 
 class NestedTagSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
-    tagged_items = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = models.Tag
-        fields = ['id', 'url', 'name', 'slug', 'color', 'tagged_items']
+        fields = ['id', 'url', 'name', 'slug', 'color']
 
 
 class NestedReportResultSerializer(serializers.ModelSerializer):

+ 22 - 0
netbox/extras/api/serializers.py

@@ -95,6 +95,28 @@ class TagSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'color', 'description', 'tagged_items']
 
 
+class TaggedObjectSerializer(serializers.Serializer):
+    tags = NestedTagSerializer(many=True, required=False)
+
+    def create(self, validated_data):
+        tags = validated_data.pop('tags', [])
+        instance = super().create(validated_data)
+
+        return self._save_tags(instance, tags)
+
+    def update(self, instance, validated_data):
+        tags = validated_data.pop('tags', [])
+        instance = super().update(instance, validated_data)
+
+        return self._save_tags(instance, tags)
+
+    def _save_tags(self, instance, tags):
+        if tags:
+            instance.tags.set(*[t.name for t in tags])
+
+        return instance
+
+
 #
 # Image attachments
 #

+ 20 - 12
netbox/extras/forms.py

@@ -1,8 +1,8 @@
 from django import forms
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
+from django.utils.safestring import mark_safe
 from mptt.forms import TreeNodeMultipleChoiceField
-from taggit.forms import TagField as TagField_
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
@@ -142,15 +142,6 @@ class CustomFieldFilterForm(forms.Form):
 # Tags
 #
 
-class TagField(TagField_):
-
-    def widget_attrs(self, widget):
-        # Apply the "tagfield" CSS class to trigger the special API-based selection widget for tags
-        return {
-            'class': 'tagfield'
-        }
-
-
 class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
 
@@ -161,14 +152,31 @@ class TagForm(BootstrapMixin, forms.ModelForm):
         ]
 
 
+class TagCSVForm(CSVModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = Tag
+        fields = Tag.csv_headers
+        help_texts = {
+            'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
+        }
+
+
 class AddRemoveTagsForm(forms.Form):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
         # Add add/remove tags fields
-        self.fields['add_tags'] = TagField(required=False)
-        self.fields['remove_tags'] = TagField(required=False)
+        self.fields['add_tags'] = DynamicModelMultipleChoiceField(
+            queryset=Tag.objects.all(),
+            required=False
+        )
+        self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
+            queryset=Tag.objects.all(),
+            required=False
+        )
 
 
 class TagFilterForm(BootstrapMixin, forms.Form):

+ 10 - 0
netbox/extras/models/tags.py

@@ -25,6 +25,8 @@ class Tag(TagBase, ChangeLoggedModel):
     objects = models.Manager()
     restricted = RestrictedQuerySet.as_manager()
 
+    csv_headers = ['name', 'slug', 'color', 'description']
+
     def get_absolute_url(self):
         return reverse('extras:tag', args=[self.slug])
 
@@ -35,6 +37,14 @@ class Tag(TagBase, ChangeLoggedModel):
             slug += "_%d" % i
         return slug
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.color,
+            self.description
+        )
+
 
 class TaggedItem(GenericTaggedItemBase):
     tag = models.ForeignKey(

+ 1 - 1
netbox/extras/tests/test_api.py

@@ -102,7 +102,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class TagTest(APIViewTestCases.APIViewTestCase):
     model = Tag
-    brief_fields = ['color', 'id', 'name', 'slug', 'tagged_items', 'url']
+    brief_fields = ['color', 'id', 'name', 'slug', 'url']
     create_data = [
         {
             'name': 'Tag 4',

+ 0 - 11
netbox/extras/tests/test_changelog.py

@@ -11,7 +11,6 @@ from utilities.testing import APITestCase
 class ChangeLogTest(APITestCase):
 
     def setUp(self):
-
         super().setUp()
 
         # Create a custom field on the Site model
@@ -31,9 +30,6 @@ class ChangeLogTest(APITestCase):
             'custom_fields': {
                 'my_field': 'ABC'
             },
-            'tags': [
-                'bar', 'foo'
-            ],
         }
         self.assertEqual(ObjectChange.objects.count(), 0)
         url = reverse('dcim-api:site-list')
@@ -50,7 +46,6 @@ class ChangeLogTest(APITestCase):
         self.assertEqual(oc.changed_object, site)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
         self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
-        self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
 
     def test_update_object(self):
         site = Site(name='Test Site 1', slug='test-site-1')
@@ -62,9 +57,6 @@ class ChangeLogTest(APITestCase):
             'custom_fields': {
                 'my_field': 'DEF'
             },
-            'tags': [
-                'abc', 'xyz'
-            ],
         }
         self.assertEqual(ObjectChange.objects.count(), 0)
         self.add_permissions('dcim.change_site')
@@ -81,7 +73,6 @@ class ChangeLogTest(APITestCase):
         self.assertEqual(oc.changed_object, site)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
         self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
-        self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
 
     def test_delete_object(self):
         site = Site(
@@ -89,7 +80,6 @@ class ChangeLogTest(APITestCase):
             slug='test-site-1'
         )
         site.save()
-        site.tags.add('foo', 'bar')
         CustomFieldValue.objects.create(
             field=CustomField.objects.get(name='my_field'),
             obj=site,
@@ -108,4 +98,3 @@ class ChangeLogTest(APITestCase):
         self.assertEqual(oc.object_repr, site.name)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
         self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
-        self.assertListEqual(sorted(oc.object_data['tags']), ['bar', 'foo'])

+ 25 - 14
netbox/extras/tests/test_tags.py

@@ -9,42 +9,53 @@ class TaggedItemTest(APITestCase):
     """
     Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
     """
-
-    def setUp(self):
-
-        super().setUp()
-
     def test_create_tagged_item(self):
+        tags = self.create_tags("Foo", "Bar", "Baz")
         data = {
             'name': 'Test Site',
             'slug': 'test-site',
-            'tags': ['Foo', 'Bar', 'Baz']
+            'tags': [t.pk for t in tags]
         }
         url = reverse('dcim-api:site-list')
         self.add_permissions('dcim.add_site')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(sorted(response.data['tags']), sorted(data['tags']))
+        self.assertListEqual(
+            sorted([t['id'] for t in response.data['tags']]),
+            sorted(data['tags'])
+        )
         site = Site.objects.get(pk=response.data['id'])
-        tags = [tag.name for tag in site.tags.all()]
-        self.assertEqual(sorted(tags), sorted(data['tags']))
+        self.assertListEqual(
+            sorted([t.name for t in site.tags.all()]),
+            sorted(["Foo", "Bar", "Baz"])
+        )
 
     def test_update_tagged_item(self):
         site = Site.objects.create(
             name='Test Site',
             slug='test-site'
         )
-        site.tags.add('Foo', 'Bar', 'Baz')
+        site.tags.add("Foo", "Bar", "Baz")
+        self.create_tags("New Tag")
         data = {
-            'tags': ['Foo', 'Bar', 'New Tag']
+            'tags': [
+                {"name": "Foo"},
+                {"name": "Bar"},
+                {"name": "New Tag"},
+            ]
         }
         self.add_permissions('dcim.change_site')
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
 
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
-        self.assertEqual(sorted(response.data['tags']), sorted(data['tags']))
+        self.assertListEqual(
+            sorted([t['name'] for t in response.data['tags']]),
+            sorted([t['name'] for t in data['tags']])
+        )
         site = Site.objects.get(pk=response.data['id'])
-        tags = [tag.name for tag in site.tags.all()]
-        self.assertEqual(sorted(tags), sorted(data['tags']))
+        self.assertListEqual(
+            sorted([t.name for t in site.tags.all()]),
+            sorted(["Foo", "Bar", "New Tag"])
+        )

+ 8 - 10
netbox/extras/tests/test_views.py

@@ -10,16 +10,7 @@ from extras.models import ConfigContext, ObjectChange, Tag
 from utilities.testing import ViewTestCases, TestCase
 
 
-# TODO: Change base class to PrimaryObjectViewTestCase
-# Blocked by #3703
-class TagTestCase(
-    ViewTestCases.GetObjectViewTestCase,
-    ViewTestCases.EditObjectViewTestCase,
-    ViewTestCases.DeleteObjectViewTestCase,
-    ViewTestCases.ListObjectsViewTestCase,
-    ViewTestCases.BulkEditObjectsViewTestCase,
-    ViewTestCases.BulkDeleteObjectsViewTestCase
-):
+class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Tag
 
     @classmethod
@@ -38,6 +29,13 @@ class TagTestCase(
             'comments': 'Some comments',
         }
 
+        cls.csv_data = (
+            "name,slug,color,description",
+            "Tag 4,tag-4,ff0000,Fourth tag",
+            "Tag 5,tag-5,00ff00,Fifth tag",
+            "Tag 6,tag-6,0000ff,Sixth tag",
+        )
+
         cls.bulk_edit_data = {
             'color': '00ff00',
         }

+ 2 - 0
netbox/extras/urls.py

@@ -9,6 +9,8 @@ urlpatterns = [
 
     # Tags
     path('tags/', views.TagListView.as_view(), name='tag_list'),
+    path('tags/add/', views.TagEditView.as_view(), name='tag_add'),
+    path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'),
     path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
     path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
     path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),

+ 19 - 14
netbox/extras/views.py

@@ -13,14 +13,13 @@ from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.utils import shallow_compare_dict
 from utilities.views import (
-    BulkDeleteView, BulkEditView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
     ObjectPermissionRequiredMixin,
 )
-from . import filters, forms
+from . import filters, forms, tables
 from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
 from .reports import get_report, get_reports
 from .scripts import get_scripts, run_script
-from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
 
 
 #
@@ -35,8 +34,7 @@ class TagListView(ObjectListView):
     )
     filterset = filters.TagFilterSet
     filterset_form = forms.TagFilterForm
-    table = TagTable
-    action_buttons = ()
+    table = tables.TagTable
 
 
 class TagView(ObjectView):
@@ -52,7 +50,7 @@ class TagView(ObjectView):
         )
 
         # Generate a table of all items tagged with this Tag
-        items_table = TaggedItemTable(tagged_items)
+        items_table = tables.TaggedItemTable(tagged_items)
         paginate = {
             'paginator_class': EnhancedPaginator,
             'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
@@ -78,13 +76,20 @@ class TagDeleteView(ObjectDeleteView):
     default_return_url = 'extras:tag_list'
 
 
+class TagBulkImportView(BulkImportView):
+    queryset = Tag.objects.all()
+    model_form = forms.TagCSVForm
+    table = tables.TagTable
+    default_return_url = 'extras:tag_list'
+
+
 class TagBulkEditView(BulkEditView):
     queryset = Tag.restricted.annotate(
         items=Count('extras_taggeditem_items', distinct=True)
     ).order_by(
         'name'
     )
-    table = TagTable
+    table = tables.TagTable
     form = forms.TagBulkEditForm
     default_return_url = 'extras:tag_list'
 
@@ -95,7 +100,7 @@ class TagBulkDeleteView(BulkDeleteView):
     ).order_by(
         'name'
     )
-    table = TagTable
+    table = tables.TagTable
     default_return_url = 'extras:tag_list'
 
 
@@ -107,7 +112,7 @@ class ConfigContextListView(ObjectListView):
     queryset = ConfigContext.objects.all()
     filterset = filters.ConfigContextFilterSet
     filterset_form = forms.ConfigContextFilterForm
-    table = ConfigContextTable
+    table = tables.ConfigContextTable
     action_buttons = ('add',)
 
 
@@ -143,7 +148,7 @@ class ConfigContextEditView(ObjectEditView):
 class ConfigContextBulkEditView(BulkEditView):
     queryset = ConfigContext.objects.all()
     filterset = filters.ConfigContextFilterSet
-    table = ConfigContextTable
+    table = tables.ConfigContextTable
     form = forms.ConfigContextBulkEditForm
     default_return_url = 'extras:configcontext_list'
 
@@ -155,7 +160,7 @@ class ConfigContextDeleteView(ObjectDeleteView):
 
 class ConfigContextBulkDeleteView(BulkDeleteView):
     queryset = ConfigContext.objects.all()
-    table = ConfigContextTable
+    table = tables.ConfigContextTable
     default_return_url = 'extras:configcontext_list'
 
 
@@ -197,7 +202,7 @@ class ObjectChangeListView(ObjectListView):
     queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
     filterset = filters.ObjectChangeFilterSet
     filterset_form = forms.ObjectChangeFilterForm
-    table = ObjectChangeTable
+    table = tables.ObjectChangeTable
     template_name = 'extras/objectchange_list.html'
     action_buttons = ('export',)
 
@@ -214,7 +219,7 @@ class ObjectChangeView(ObjectView):
         ).exclude(
             pk=objectchange.pk
         )
-        related_changes_table = ObjectChangeTable(
+        related_changes_table = tables.ObjectChangeTable(
             data=related_changes[:50],
             orderable=False
         )
@@ -268,7 +273,7 @@ class ObjectChangeLogView(View):
             Q(changed_object_type=content_type, changed_object_id=obj.pk) |
             Q(related_object_type=content_type, related_object_id=obj.pk)
         )
-        objectchanges_table = ObjectChangeTable(
+        objectchanges_table = tables.ObjectChangeTable(
             data=objectchanges,
             orderable=False
         )

+ 7 - 13
netbox/ipam/api/serializers.py

@@ -3,11 +3,11 @@ from collections import OrderedDict
 from rest_framework import serializers
 from rest_framework.reverse import reverse
 from rest_framework.validators import UniqueTogetherValidator
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
 from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
 from ipam.choices import *
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -22,9 +22,8 @@ from .nested_serializers import *
 # VRFs
 #
 
-class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
     ipaddress_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
 
@@ -48,10 +47,9 @@ class RIRSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'is_private', 'description', 'aggregate_count']
 
 
-class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     rir = NestedRIRSerializer()
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = Aggregate
@@ -98,13 +96,12 @@ class VLANGroupSerializer(ValidatedModelSerializer):
         return data
 
 
-class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     site = NestedSiteSerializer(required=False, allow_null=True)
     group = NestedVLANGroupSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=VLANStatusChoices, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
     prefix_count = serializers.IntegerField(read_only=True)
 
     class Meta:
@@ -133,7 +130,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
 # Prefixes
 #
 
-class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
@@ -141,7 +138,6 @@ class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
     vlan = NestedVLANSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=PrefixStatusChoices, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = Prefix
@@ -226,7 +222,7 @@ class IPAddressInterfaceSerializer(WritableNestedSerializer):
         return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
 
 
-class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -235,7 +231,6 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
     interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
     nat_outside = NestedIPAddressSerializer(read_only=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = IPAddress
@@ -270,7 +265,7 @@ class AvailableIPSerializer(serializers.Serializer):
 # Services
 #
 
-class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     device = NestedDeviceSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
     protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
@@ -280,7 +275,6 @@ class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
         required=False,
         many=True
     )
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = Service

+ 17 - 7
netbox/ipam/forms.py

@@ -4,8 +4,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from dcim.models import Device, Interface, Rack, Region, Site
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
-    TagField,
 )
+from extras.models import Tag
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
@@ -33,7 +33,8 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
 #
 
 class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -141,7 +142,8 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
     rir = DynamicModelChoiceField(
         queryset=RIR.objects.all()
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -292,7 +294,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         queryset=Role.objects.all(),
         required=False
     )
-    tags = TagField(required=False)
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = Prefix
@@ -584,7 +589,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
         required=False,
         label='Make this the primary IP for the device/VM'
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -993,7 +999,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         queryset=Role.objects.all(),
         required=False
     )
-    tags = TagField(required=False)
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = VLAN
@@ -1160,7 +1169,8 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
         min_value=SERVICE_PORT_MIN,
         max_value=SERVICE_PORT_MAX
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 

+ 6 - 6
netbox/ipam/tests/test_views.py

@@ -33,7 +33,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'tenant': tenants[0].pk,
             'enforce_unique': True,
             'description': 'A new VRF',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -100,7 +100,7 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'rir': rirs[1].pk,
             'date_added': datetime.date(2020, 1, 1),
             'description': 'A new aggregate',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -183,7 +183,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'role': roles[1].pk,
             'is_pool': True,
             'description': 'A new prefix',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -232,7 +232,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'nat_inside': None,
             'dns_name': 'example',
             'description': 'A new IP address',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -320,7 +320,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'status': VLANStatusChoices.STATUS_RESERVED,
             'role': roles[1].pk,
             'description': 'A new VLAN',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -376,7 +376,7 @@ class ServiceTestCase(
             'port': 999,
             'ipaddresses': [],
             'description': 'A new service',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (

+ 0 - 2
netbox/netbox/settings.py

@@ -285,7 +285,6 @@ INSTALLED_APPS = [
     'mptt',
     'rest_framework',
     'taggit',
-    'taggit_serializer',
     'timezone_field',
     'circuits',
     'dcim',
@@ -489,7 +488,6 @@ SWAGGER_SETTINGS = {
         'utilities.custom_inspectors.JSONFieldInspector',
         'utilities.custom_inspectors.NullableBooleanFieldInspector',
         'utilities.custom_inspectors.CustomChoiceFieldInspector',
-        'utilities.custom_inspectors.TagListFieldInspector',
         'utilities.custom_inspectors.SerializedPKRelatedFieldInspector',
         'drf_yasg.inspectors.CamelCaseJSONFilter',
         'drf_yasg.inspectors.ReferencingSerializerInspector',

+ 2 - 3
netbox/secrets/api/serializers.py

@@ -1,8 +1,8 @@
 from rest_framework import serializers
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 from dcim.api.nested_serializers import NestedDeviceSerializer
 from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
 from secrets.models import Secret, SecretRole
 from utilities.api import ValidatedModelSerializer
 from .nested_serializers import *
@@ -20,11 +20,10 @@ class SecretRoleSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'description', 'secret_count']
 
 
-class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     device = NestedDeviceSerializer()
     role = NestedSecretRoleSerializer()
     plaintext = serializers.CharField()
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = Secret

+ 3 - 2
netbox/secrets/forms.py

@@ -5,8 +5,8 @@ from django import forms
 from dcim.models import Device
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
-    TagField,
 )
+from extras.models import Tag
 from utilities.forms import (
     APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
     DynamicModelMultipleChoiceField, SlugField, StaticSelect2Multiple, TagFilterField,
@@ -90,7 +90,8 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
     role = DynamicModelChoiceField(
         queryset=SecretRole.objects.all()
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 

+ 1 - 1
netbox/templates/extras/tag.html

@@ -85,7 +85,7 @@
                     <tr>
                         <td>Description</td>
                         <td>
-                            {{ tag.description }}
+                            {{ tag.description|placeholder }}
                         </td>
                 </table>
             </div>

+ 6 - 0
netbox/templates/inc/nav_menu.html

@@ -102,6 +102,12 @@
                         <li class="divider"></li>
                         <li class="dropdown-header">Tags</li>
                         <li{% if not perms.extras.view_tag %} class="disabled"{% endif %}>
+                            {% if perms.extras.add_tag %}
+                                <div class="buttons pull-right">
+                                    <a href="{% url 'extras:tag_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
+                                    <a href="{% url 'extras:tag_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
+                                </div>
+                            {% endif %}
                             <a href="{% url 'extras:tag_list' %}">Tags</a>
                         </li>
                     </ul>

+ 2 - 3
netbox/tenancy/api/serializers.py

@@ -1,7 +1,7 @@
 from rest_framework import serializers
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
 from tenancy.models import Tenant, TenantGroup
 from utilities.api import ValidatedModelSerializer
 from .nested_serializers import *
@@ -20,9 +20,8 @@ class TenantGroupSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'parent', 'description', 'tenant_count']
 
 
-class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     group = NestedTenantGroupSerializer(required=False)
-    tags = TagListSerializerField(required=False)
     circuit_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     ipaddress_count = serializers.IntegerField(read_only=True)

+ 3 - 2
netbox/tenancy/forms.py

@@ -2,8 +2,8 @@ from django import forms
 
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
-    TagField,
 )
+from extras.models import Tag
 from utilities.forms import (
     APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,
     DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField,
@@ -57,7 +57,8 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
         required=False
     )
     comments = CommentField()
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 

+ 1 - 1
netbox/tenancy/tests/test_views.py

@@ -55,7 +55,7 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'group': tenant_groups[1].pk,
             'description': 'A new tenant',
             'comments': 'Some comments',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (

+ 1 - 15
netbox/utilities/custom_inspectors.py

@@ -1,10 +1,9 @@
 from django.contrib.postgres.fields import JSONField
 from drf_yasg import openapi
-from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector, SwaggerAutoSchema
+from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, SwaggerAutoSchema
 from drf_yasg.utils import get_serializer_ref_name
 from rest_framework.fields import ChoiceField
 from rest_framework.relations import ManyRelatedField
-from taggit_serializer.serializers import TagListSerializerField
 
 from dcim.api.serializers import InterfaceSerializer as DeviceInterfaceSerializer
 from extras.api.customfields import CustomFieldsSerializer
@@ -56,19 +55,6 @@ class SerializedPKRelatedFieldInspector(FieldInspector):
         return NotHandled
 
 
-class TagListFieldInspector(FieldInspector):
-    def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
-        SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
-        if isinstance(field, TagListSerializerField):
-            child_schema = self.probe_field_inspectors(field.child, ChildSwaggerType, use_references)
-            return SwaggerType(
-                type=openapi.TYPE_ARRAY,
-                items=child_schema,
-            )
-
-        return NotHandled
-
-
 class CustomChoiceFieldInspector(FieldInspector):
     def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
         # this returns a callable which extracts title, description and other stuff

+ 3 - 0
netbox/utilities/querysets.py

@@ -12,6 +12,9 @@ class DummyQuerySet:
     def __init__(self, queryset):
         self._cache = [obj for obj in queryset.all()]
 
+    def __iter__(self):
+        return iter(self._cache)
+
     def all(self):
         return self._cache
 

+ 8 - 1
netbox/utilities/testing/utils.py

@@ -14,7 +14,14 @@ def post_data(data):
         if value is None:
             ret[key] = ''
         elif type(value) in (list, tuple):
-            ret[key] = value
+            if value and hasattr(value[0], 'pk'):
+                # Value is a list of instances
+                ret[key] = [v.pk for v in value]
+            else:
+                ret[key] = value
+        elif hasattr(value, 'pk'):
+            # Value is an instance
+            ret[key] = value.pk
         else:
             ret[key] = str(value)
 

+ 17 - 2
netbox/utilities/testing/views.py

@@ -6,8 +6,10 @@ from django.db.models import ForeignKey, ManyToManyField
 from django.forms.models import model_to_dict
 from django.test import Client, TestCase as _TestCase, override_settings
 from django.urls import reverse, NoReverseMatch
+from django.utils.text import slugify
 from netaddr import IPNetwork
 
+from extras.models import Tag
 from users.models import ObjectPermission
 from utilities.permissions import resolve_permission_ct
 from .utils import disable_warnings, post_data
@@ -49,7 +51,7 @@ class TestCase(_TestCase):
             obj_perm.object_types.add(ct)
 
     #
-    # Convenience methods
+    # Custom assertions
     #
 
     def assertHttpStatus(self, response, expected_status):
@@ -75,7 +77,7 @@ class TestCase(_TestCase):
 
             # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
             if key == 'tags':
-                model_dict[key] = ','.join(sorted([tag.name for tag in value]))
+                model_dict[key] = sorted(value)
 
             # Convert ManyToManyField to list of instance PKs
             elif model_dict[key] and type(value) in (list, tuple) and hasattr(value[0], 'pk'):
@@ -108,6 +110,19 @@ class TestCase(_TestCase):
 
         self.assertDictEqual(model_dict, relevant_data)
 
+    #
+    # Convenience methods
+    #
+
+    @classmethod
+    def create_tags(cls, *names):
+        """
+        Create and return a Tag instance for each name given.
+        """
+        tags = [Tag(name=name, slug=slugify(name)) for name in names]
+        Tag.objects.bulk_create(tags)
+        return tags
+
 
 #
 # UI Tests

+ 5 - 8
netbox/virtualization/api/serializers.py

@@ -1,11 +1,11 @@
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
-from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices
+from dcim.choices import InterfaceModeChoices
 from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.models import VLAN
 from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -35,12 +35,11 @@ class ClusterGroupSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'description', 'cluster_count']
 
 
-class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     type = NestedClusterTypeSerializer()
     group = NestedClusterGroupSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
     device_count = serializers.IntegerField(read_only=True)
     virtualmachine_count = serializers.IntegerField(read_only=True)
 
@@ -56,7 +55,7 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
 # Virtual machines
 #
 
-class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class VirtualMachineSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
     site = NestedSiteSerializer(read_only=True)
     cluster = NestedClusterSerializer()
@@ -66,7 +65,6 @@ class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = VirtualMachine
@@ -97,7 +95,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 # VM interfaces
 #
 
-class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
+class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     virtual_machine = NestedVirtualMachineSerializer()
     type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
@@ -108,7 +106,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
         required=False,
         many=True
     )
-    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = Interface

+ 11 - 7
netbox/virtualization/forms.py

@@ -7,8 +7,8 @@ from dcim.forms import INTERFACE_MODE_HELP_TEXT
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
-    TagField,
 )
+from extras.models import Tag
 from ipam.models import IPAddress, VLAN
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
@@ -83,7 +83,8 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         required=False
     )
     comments = CommentField()
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -312,13 +313,14 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         queryset=Platform.objects.all(),
         required=False
     )
-    tags = TagField(
-        required=False
-    )
     local_context_data = JSONField(
         required=False,
         label=''
     )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = VirtualMachine
@@ -590,7 +592,8 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
             },
         )
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 
@@ -697,7 +700,8 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
             },
         )
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
     )
 

+ 6 - 4
netbox/virtualization/tests/test_views.py

@@ -97,7 +97,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'tenant': None,
             'site': sites[1].pk,
             'comments': 'Some comments',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
         }
 
         cls.csv_data = (
@@ -161,7 +161,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'memory': 32768,
             'disk': 4000,
             'comments': 'Some comments',
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
             'local_context_data': None,
         }
 
@@ -228,6 +228,8 @@ class InterfaceTestCase(
         )
         VLAN.objects.bulk_create(vlans)
 
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'virtual_machine': virtualmachines[1].pk,
             'name': 'Interface X',
@@ -240,7 +242,7 @@ class InterfaceTestCase(
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'untagged_vlan': vlans[0].pk,
             'tagged_vlans': [v.pk for v in vlans[1:4]],
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_create_data = {
@@ -255,7 +257,7 @@ class InterfaceTestCase(
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'untagged_vlan': vlans[0].pk,
             'tagged_vlans': [v.pk for v in vlans[1:4]],
-            'tags': 'Alpha,Bravo,Charlie',
+            'tags': tags,
         }
 
         cls.bulk_edit_data = {

+ 0 - 1
requirements.txt

@@ -9,7 +9,6 @@ django-prometheus==2.0.0
 django-rq==2.3.2
 django-tables2==2.3.1
 django-taggit==1.2.0
-django-taggit-serializer==0.1.7
 django-timezone-field==4.0
 djangorestframework==3.11.0
 drf-yasg[validation]==1.17.1