Przeglądaj źródła

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

Closes #3703: Restrict tag creation
Jeremy Stretch 5 lat temu
rodzic
commit
2d4694e72d
37 zmienionych plików z 349 dodań i 250 usunięć
  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
 # https://github.com/alex/django-taggit
 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
 # A Django field for representing time zones
 # https://github.com/mfogel/django-timezone-field/
 # https://github.com/mfogel/django-timezone-field/
 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
 ### 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
 * [#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
 * [#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.)
 * 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}`.
 * `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
 ### 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.
 * 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 rest_framework import serializers
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 
 from circuits.choices import CircuitStatusChoices
 from circuits.choices import CircuitStatusChoices
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
 from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
 from dcim.api.serializers import ConnectedEndpointSerializer
 from dcim.api.serializers import ConnectedEndpointSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
 from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
 from .nested_serializers import *
 from .nested_serializers import *
@@ -15,8 +15,7 @@ from .nested_serializers import *
 # Providers
 # Providers
 #
 #
 
 
-class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
-    tags = TagListSerializerField(required=False)
+class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     circuit_count = serializers.IntegerField(read_only=True)
     circuit_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
@@ -49,14 +48,13 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
         fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
 
 
 
 
-class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     provider = NestedProviderSerializer()
     provider = NestedProviderSerializer()
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     type = NestedCircuitTypeSerializer()
     type = NestedCircuitTypeSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     termination_a = CircuitCircuitTerminationSerializer(read_only=True)
     termination_a = CircuitCircuitTerminationSerializer(read_only=True)
     termination_z = CircuitCircuitTerminationSerializer(read_only=True)
     termination_z = CircuitCircuitTerminationSerializer(read_only=True)
-    tags = TagListSerializerField(required=False)
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit

+ 5 - 3
netbox/circuits/forms.py

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

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

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

+ 54 - 26
netbox/dcim/forms.py

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

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

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

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

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

+ 20 - 12
netbox/extras/forms.py

@@ -1,8 +1,8 @@
 from django import forms
 from django import forms
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.utils.safestring import mark_safe
 from mptt.forms import TreeNodeMultipleChoiceField
 from mptt.forms import TreeNodeMultipleChoiceField
-from taggit.forms import TagField as TagField_
 
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
@@ -142,15 +142,6 @@ class CustomFieldFilterForm(forms.Form):
 # Tags
 # 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):
 class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     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):
 class AddRemoveTagsForm(forms.Form):
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Add add/remove tags fields
         # 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):
 class TagFilterForm(BootstrapMixin, forms.Form):

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

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

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

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

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

@@ -11,7 +11,6 @@ from utilities.testing import APITestCase
 class ChangeLogTest(APITestCase):
 class ChangeLogTest(APITestCase):
 
 
     def setUp(self):
     def setUp(self):
-
         super().setUp()
         super().setUp()
 
 
         # Create a custom field on the Site model
         # Create a custom field on the Site model
@@ -31,9 +30,6 @@ class ChangeLogTest(APITestCase):
             'custom_fields': {
             'custom_fields': {
                 'my_field': 'ABC'
                 'my_field': 'ABC'
             },
             },
-            'tags': [
-                'bar', 'foo'
-            ],
         }
         }
         self.assertEqual(ObjectChange.objects.count(), 0)
         self.assertEqual(ObjectChange.objects.count(), 0)
         url = reverse('dcim-api:site-list')
         url = reverse('dcim-api:site-list')
@@ -50,7 +46,6 @@ class ChangeLogTest(APITestCase):
         self.assertEqual(oc.changed_object, site)
         self.assertEqual(oc.changed_object, site)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
         self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
         self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
-        self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
 
 
     def test_update_object(self):
     def test_update_object(self):
         site = Site(name='Test Site 1', slug='test-site-1')
         site = Site(name='Test Site 1', slug='test-site-1')
@@ -62,9 +57,6 @@ class ChangeLogTest(APITestCase):
             'custom_fields': {
             'custom_fields': {
                 'my_field': 'DEF'
                 'my_field': 'DEF'
             },
             },
-            'tags': [
-                'abc', 'xyz'
-            ],
         }
         }
         self.assertEqual(ObjectChange.objects.count(), 0)
         self.assertEqual(ObjectChange.objects.count(), 0)
         self.add_permissions('dcim.change_site')
         self.add_permissions('dcim.change_site')
@@ -81,7 +73,6 @@ class ChangeLogTest(APITestCase):
         self.assertEqual(oc.changed_object, site)
         self.assertEqual(oc.changed_object, site)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
         self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
         self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
-        self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
 
 
     def test_delete_object(self):
     def test_delete_object(self):
         site = Site(
         site = Site(
@@ -89,7 +80,6 @@ class ChangeLogTest(APITestCase):
             slug='test-site-1'
             slug='test-site-1'
         )
         )
         site.save()
         site.save()
-        site.tags.add('foo', 'bar')
         CustomFieldValue.objects.create(
         CustomFieldValue.objects.create(
             field=CustomField.objects.get(name='my_field'),
             field=CustomField.objects.get(name='my_field'),
             obj=site,
             obj=site,
@@ -108,4 +98,3 @@ class ChangeLogTest(APITestCase):
         self.assertEqual(oc.object_repr, site.name)
         self.assertEqual(oc.object_repr, site.name)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
         self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
         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).
     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):
     def test_create_tagged_item(self):
+        tags = self.create_tags("Foo", "Bar", "Baz")
         data = {
         data = {
             'name': 'Test Site',
             'name': 'Test Site',
             'slug': 'test-site',
             'slug': 'test-site',
-            'tags': ['Foo', 'Bar', 'Baz']
+            'tags': [t.pk for t in tags]
         }
         }
         url = reverse('dcim-api:site-list')
         url = reverse('dcim-api:site-list')
         self.add_permissions('dcim.add_site')
         self.add_permissions('dcim.add_site')
 
 
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         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'])
         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):
     def test_update_tagged_item(self):
         site = Site.objects.create(
         site = Site.objects.create(
             name='Test Site',
             name='Test Site',
             slug='test-site'
             slug='test-site'
         )
         )
-        site.tags.add('Foo', 'Bar', 'Baz')
+        site.tags.add("Foo", "Bar", "Baz")
+        self.create_tags("New Tag")
         data = {
         data = {
-            'tags': ['Foo', 'Bar', 'New Tag']
+            'tags': [
+                {"name": "Foo"},
+                {"name": "Bar"},
+                {"name": "New Tag"},
+            ]
         }
         }
         self.add_permissions('dcim.change_site')
         self.add_permissions('dcim.change_site')
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
         url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
 
 
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         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'])
         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
 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
     model = Tag
 
 
     @classmethod
     @classmethod
@@ -38,6 +29,13 @@ class TagTestCase(
             'comments': 'Some comments',
             '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 = {
         cls.bulk_edit_data = {
             'color': '00ff00',
             'color': '00ff00',
         }
         }

+ 2 - 0
netbox/extras/urls.py

@@ -9,6 +9,8 @@ urlpatterns = [
 
 
     # Tags
     # Tags
     path('tags/', views.TagListView.as_view(), name='tag_list'),
     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/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
     path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
     path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
     path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),
     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.paginator import EnhancedPaginator
 from utilities.utils import shallow_compare_dict
 from utilities.utils import shallow_compare_dict
 from utilities.views import (
 from utilities.views import (
-    BulkDeleteView, BulkEditView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
     ObjectPermissionRequiredMixin,
     ObjectPermissionRequiredMixin,
 )
 )
-from . import filters, forms
+from . import filters, forms, tables
 from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
 from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
 from .reports import get_report, get_reports
 from .reports import get_report, get_reports
 from .scripts import get_scripts, run_script
 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 = filters.TagFilterSet
     filterset_form = forms.TagFilterForm
     filterset_form = forms.TagFilterForm
-    table = TagTable
-    action_buttons = ()
+    table = tables.TagTable
 
 
 
 
 class TagView(ObjectView):
 class TagView(ObjectView):
@@ -52,7 +50,7 @@ class TagView(ObjectView):
         )
         )
 
 
         # Generate a table of all items tagged with this Tag
         # Generate a table of all items tagged with this Tag
-        items_table = TaggedItemTable(tagged_items)
+        items_table = tables.TaggedItemTable(tagged_items)
         paginate = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
             'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
             'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
@@ -78,13 +76,20 @@ class TagDeleteView(ObjectDeleteView):
     default_return_url = 'extras:tag_list'
     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):
 class TagBulkEditView(BulkEditView):
     queryset = Tag.restricted.annotate(
     queryset = Tag.restricted.annotate(
         items=Count('extras_taggeditem_items', distinct=True)
         items=Count('extras_taggeditem_items', distinct=True)
     ).order_by(
     ).order_by(
         'name'
         'name'
     )
     )
-    table = TagTable
+    table = tables.TagTable
     form = forms.TagBulkEditForm
     form = forms.TagBulkEditForm
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
 
 
@@ -95,7 +100,7 @@ class TagBulkDeleteView(BulkDeleteView):
     ).order_by(
     ).order_by(
         'name'
         'name'
     )
     )
-    table = TagTable
+    table = tables.TagTable
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
 
 
 
 
@@ -107,7 +112,7 @@ class ConfigContextListView(ObjectListView):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     filterset = filters.ConfigContextFilterSet
     filterset = filters.ConfigContextFilterSet
     filterset_form = forms.ConfigContextFilterForm
     filterset_form = forms.ConfigContextFilterForm
-    table = ConfigContextTable
+    table = tables.ConfigContextTable
     action_buttons = ('add',)
     action_buttons = ('add',)
 
 
 
 
@@ -143,7 +148,7 @@ class ConfigContextEditView(ObjectEditView):
 class ConfigContextBulkEditView(BulkEditView):
 class ConfigContextBulkEditView(BulkEditView):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     filterset = filters.ConfigContextFilterSet
     filterset = filters.ConfigContextFilterSet
-    table = ConfigContextTable
+    table = tables.ConfigContextTable
     form = forms.ConfigContextBulkEditForm
     form = forms.ConfigContextBulkEditForm
     default_return_url = 'extras:configcontext_list'
     default_return_url = 'extras:configcontext_list'
 
 
@@ -155,7 +160,7 @@ class ConfigContextDeleteView(ObjectDeleteView):
 
 
 class ConfigContextBulkDeleteView(BulkDeleteView):
 class ConfigContextBulkDeleteView(BulkDeleteView):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
-    table = ConfigContextTable
+    table = tables.ConfigContextTable
     default_return_url = 'extras:configcontext_list'
     default_return_url = 'extras:configcontext_list'
 
 
 
 
@@ -197,7 +202,7 @@ class ObjectChangeListView(ObjectListView):
     queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
     queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
     filterset = filters.ObjectChangeFilterSet
     filterset = filters.ObjectChangeFilterSet
     filterset_form = forms.ObjectChangeFilterForm
     filterset_form = forms.ObjectChangeFilterForm
-    table = ObjectChangeTable
+    table = tables.ObjectChangeTable
     template_name = 'extras/objectchange_list.html'
     template_name = 'extras/objectchange_list.html'
     action_buttons = ('export',)
     action_buttons = ('export',)
 
 
@@ -214,7 +219,7 @@ class ObjectChangeView(ObjectView):
         ).exclude(
         ).exclude(
             pk=objectchange.pk
             pk=objectchange.pk
         )
         )
-        related_changes_table = ObjectChangeTable(
+        related_changes_table = tables.ObjectChangeTable(
             data=related_changes[:50],
             data=related_changes[:50],
             orderable=False
             orderable=False
         )
         )
@@ -268,7 +273,7 @@ class ObjectChangeLogView(View):
             Q(changed_object_type=content_type, changed_object_id=obj.pk) |
             Q(changed_object_type=content_type, changed_object_id=obj.pk) |
             Q(related_object_type=content_type, related_object_id=obj.pk)
             Q(related_object_type=content_type, related_object_id=obj.pk)
         )
         )
-        objectchanges_table = ObjectChangeTable(
+        objectchanges_table = tables.ObjectChangeTable(
             data=objectchanges,
             data=objectchanges,
             orderable=False
             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 import serializers
 from rest_framework.reverse import reverse
 from rest_framework.reverse import reverse
 from rest_framework.validators import UniqueTogetherValidator
 from rest_framework.validators import UniqueTogetherValidator
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
 from dcim.models import Interface
 from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
 from ipam.choices import *
 from ipam.choices import *
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -22,9 +22,8 @@ from .nested_serializers import *
 # VRFs
 # VRFs
 #
 #
 
 
-class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
     ipaddress_count = serializers.IntegerField(read_only=True)
     ipaddress_count = serializers.IntegerField(read_only=True)
     prefix_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']
         fields = ['id', 'name', 'slug', 'is_private', 'description', 'aggregate_count']
 
 
 
 
-class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     rir = NestedRIRSerializer()
     rir = NestedRIRSerializer()
-    tags = TagListSerializerField(required=False)
 
 
     class Meta:
     class Meta:
         model = Aggregate
         model = Aggregate
@@ -98,13 +96,12 @@ class VLANGroupSerializer(ValidatedModelSerializer):
         return data
         return data
 
 
 
 
-class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     site = NestedSiteSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
     group = NestedVLANGroupSerializer(required=False, allow_null=True)
     group = NestedVLANGroupSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=VLANStatusChoices, required=False)
     status = ChoiceField(choices=VLANStatusChoices, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
     role = NestedRoleSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
     prefix_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
@@ -133,7 +130,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
 # Prefixes
 # Prefixes
 #
 #
 
 
-class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
     vrf = NestedVRFSerializer(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)
     vlan = NestedVLANSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=PrefixStatusChoices, required=False)
     status = ChoiceField(choices=PrefixStatusChoices, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
     role = NestedRoleSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
@@ -226,7 +222,7 @@ class IPAddressInterfaceSerializer(WritableNestedSerializer):
         return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
         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)
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(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)
     interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
     nat_outside = NestedIPAddressSerializer(read_only=True)
     nat_outside = NestedIPAddressSerializer(read_only=True)
-    tags = TagListSerializerField(required=False)
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
@@ -270,7 +265,7 @@ class AvailableIPSerializer(serializers.Serializer):
 # Services
 # Services
 #
 #
 
 
-class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     device = NestedDeviceSerializer(required=False, allow_null=True)
     device = NestedDeviceSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
     protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
     protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
@@ -280,7 +275,6 @@ class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
         required=False,
         required=False,
         many=True
         many=True
     )
     )
-    tags = TagListSerializerField(required=False)
 
 
     class Meta:
     class Meta:
         model = Service
         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 dcim.models import Device, Interface, Rack, Region, Site
 from extras.forms import (
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
-    TagField,
 )
 )
+from extras.models import Tag
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
@@ -33,7 +33,8 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
 #
 #
 
 
 class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
         required=False
     )
     )
 
 
@@ -141,7 +142,8 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
     rir = DynamicModelChoiceField(
     rir = DynamicModelChoiceField(
         queryset=RIR.objects.all()
         queryset=RIR.objects.all()
     )
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
         required=False
     )
     )
 
 
@@ -292,7 +294,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False
         required=False
     )
     )
-    tags = TagField(required=False)
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
@@ -584,7 +589,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
         required=False,
         required=False,
         label='Make this the primary IP for the device/VM'
         label='Make this the primary IP for the device/VM'
     )
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
         required=False
     )
     )
 
 
@@ -993,7 +999,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False
         required=False
     )
     )
-    tags = TagField(required=False)
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
@@ -1160,7 +1169,8 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
         min_value=SERVICE_PORT_MIN,
         min_value=SERVICE_PORT_MIN,
         max_value=SERVICE_PORT_MAX
         max_value=SERVICE_PORT_MAX
     )
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
         required=False
     )
     )
 
 

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

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

+ 0 - 2
netbox/netbox/settings.py

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

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

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

+ 3 - 2
netbox/secrets/forms.py

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

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

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

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

@@ -102,6 +102,12 @@
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Tags</li>
                         <li class="dropdown-header">Tags</li>
                         <li{% if not perms.extras.view_tag %} class="disabled"{% endif %}>
                         <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>
                             <a href="{% url 'extras:tag_list' %}">Tags</a>
                         </li>
                         </li>
                     </ul>
                     </ul>

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

@@ -1,7 +1,7 @@
 from rest_framework import serializers
 from rest_framework import serializers
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.api import ValidatedModelSerializer
 from utilities.api import ValidatedModelSerializer
 from .nested_serializers import *
 from .nested_serializers import *
@@ -20,9 +20,8 @@ class TenantGroupSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'parent', 'description', 'tenant_count']
         fields = ['id', 'name', 'slug', 'parent', 'description', 'tenant_count']
 
 
 
 
-class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     group = NestedTenantGroupSerializer(required=False)
     group = NestedTenantGroupSerializer(required=False)
-    tags = TagListSerializerField(required=False)
     circuit_count = serializers.IntegerField(read_only=True)
     circuit_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     ipaddress_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 (
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
     AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
-    TagField,
 )
 )
+from extras.models import Tag
 from utilities.forms import (
 from utilities.forms import (
     APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,
     APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,
     DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField,
     DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField,
@@ -57,7 +57,8 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
         required=False
         required=False
     )
     )
     comments = CommentField()
     comments = CommentField()
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
         required=False
     )
     )
 
 

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

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

+ 1 - 15
netbox/utilities/custom_inspectors.py

@@ -1,10 +1,9 @@
 from django.contrib.postgres.fields import JSONField
 from django.contrib.postgres.fields import JSONField
 from drf_yasg import openapi
 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 drf_yasg.utils import get_serializer_ref_name
 from rest_framework.fields import ChoiceField
 from rest_framework.fields import ChoiceField
 from rest_framework.relations import ManyRelatedField
 from rest_framework.relations import ManyRelatedField
-from taggit_serializer.serializers import TagListSerializerField
 
 
 from dcim.api.serializers import InterfaceSerializer as DeviceInterfaceSerializer
 from dcim.api.serializers import InterfaceSerializer as DeviceInterfaceSerializer
 from extras.api.customfields import CustomFieldsSerializer
 from extras.api.customfields import CustomFieldsSerializer
@@ -56,19 +55,6 @@ class SerializedPKRelatedFieldInspector(FieldInspector):
         return NotHandled
         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):
 class CustomChoiceFieldInspector(FieldInspector):
     def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
     def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
         # this returns a callable which extracts title, description and other stuff
         # 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):
     def __init__(self, queryset):
         self._cache = [obj for obj in queryset.all()]
         self._cache = [obj for obj in queryset.all()]
 
 
+    def __iter__(self):
+        return iter(self._cache)
+
     def all(self):
     def all(self):
         return self._cache
         return self._cache
 
 

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

@@ -14,7 +14,14 @@ def post_data(data):
         if value is None:
         if value is None:
             ret[key] = ''
             ret[key] = ''
         elif type(value) in (list, tuple):
         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:
         else:
             ret[key] = str(value)
             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.forms.models import model_to_dict
 from django.test import Client, TestCase as _TestCase, override_settings
 from django.test import Client, TestCase as _TestCase, override_settings
 from django.urls import reverse, NoReverseMatch
 from django.urls import reverse, NoReverseMatch
+from django.utils.text import slugify
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 
 
+from extras.models import Tag
 from users.models import ObjectPermission
 from users.models import ObjectPermission
 from utilities.permissions import resolve_permission_ct
 from utilities.permissions import resolve_permission_ct
 from .utils import disable_warnings, post_data
 from .utils import disable_warnings, post_data
@@ -49,7 +51,7 @@ class TestCase(_TestCase):
             obj_perm.object_types.add(ct)
             obj_perm.object_types.add(ct)
 
 
     #
     #
-    # Convenience methods
+    # Custom assertions
     #
     #
 
 
     def assertHttpStatus(self, response, expected_status):
     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)
             # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
             if key == 'tags':
             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
             # Convert ManyToManyField to list of instance PKs
             elif model_dict[key] and type(value) in (list, tuple) and hasattr(value[0], 'pk'):
             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)
         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
 # UI Tests

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

@@ -1,11 +1,11 @@
 from drf_yasg.utils import swagger_serializer_method
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 from rest_framework import serializers
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 
 from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
 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 dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -35,12 +35,11 @@ class ClusterGroupSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'description', 'cluster_count']
         fields = ['id', 'name', 'slug', 'description', 'cluster_count']
 
 
 
 
-class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     type = NestedClusterTypeSerializer()
     type = NestedClusterTypeSerializer()
     group = NestedClusterGroupSerializer(required=False, allow_null=True)
     group = NestedClusterGroupSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     virtualmachine_count = serializers.IntegerField(read_only=True)
     virtualmachine_count = serializers.IntegerField(read_only=True)
 
 
@@ -56,7 +55,7 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
 # Virtual machines
 # Virtual machines
 #
 #
 
 
-class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class VirtualMachineSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
     status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
     site = NestedSiteSerializer(read_only=True)
     site = NestedSiteSerializer(read_only=True)
     cluster = NestedClusterSerializer()
     cluster = NestedClusterSerializer()
@@ -66,7 +65,6 @@ class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
-    tags = TagListSerializerField(required=False)
 
 
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
@@ -97,7 +95,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 # VM interfaces
 # VM interfaces
 #
 #
 
 
-class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
+class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     virtual_machine = NestedVirtualMachineSerializer()
     virtual_machine = NestedVirtualMachineSerializer()
     type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
     type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
@@ -108,7 +106,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
         required=False,
         required=False,
         many=True
         many=True
     )
     )
-    tags = TagListSerializerField(required=False)
 
 
     class Meta:
     class Meta:
         model = Interface
         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 dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import (
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
-    TagField,
 )
 )
+from extras.models import Tag
 from ipam.models import IPAddress, VLAN
 from ipam.models import IPAddress, VLAN
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -83,7 +83,8 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         required=False
         required=False
     )
     )
     comments = CommentField()
     comments = CommentField()
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
         required=False
     )
     )
 
 
@@ -312,13 +313,14 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         required=False
         required=False
     )
     )
-    tags = TagField(
-        required=False
-    )
     local_context_data = JSONField(
     local_context_data = JSONField(
         required=False,
         required=False,
         label=''
         label=''
     )
     )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
@@ -590,7 +592,8 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
             },
             },
         )
         )
     )
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
         required=False
     )
     )
 
 
@@ -697,7 +700,8 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
             },
             },
         )
         )
     )
     )
-    tags = TagField(
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
         required=False
         required=False
     )
     )
 
 

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

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

+ 0 - 1
requirements.txt

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