Bläddra i källkod

Add tags to organizational & nested group models

jeremystretch 4 år sedan
förälder
incheckning
cfb3897047
52 ändrade filer med 463 tillägg och 154 borttagningar
  1. 2 2
      docs/development/models.md
  2. 0 3
      docs/models/extras/tag.md
  3. 3 5
      netbox/circuits/api/serializers.py
  4. 1 1
      netbox/circuits/api/views.py
  5. 1 1
      netbox/circuits/forms/bulk_edit.py
  6. 5 1
      netbox/circuits/forms/models.py
  7. 20 0
      netbox/circuits/migrations/0003_extend_tag_support.py
  8. 1 1
      netbox/circuits/models.py
  9. 4 1
      netbox/circuits/tables.py
  10. 3 0
      netbox/circuits/tests/test_views.py
  11. 16 17
      netbox/dcim/api/serializers.py
  12. 7 7
      netbox/dcim/api/views.py
  13. 7 7
      netbox/dcim/forms/bulk_edit.py
  14. 36 8
      netbox/dcim/forms/models.py
  15. 50 0
      netbox/dcim/migrations/0138_extend_tag_support.py
  16. 3 3
      netbox/dcim/models/devices.py
  17. 1 1
      netbox/dcim/models/racks.py
  18. 3 3
      netbox/dcim/models/sites.py
  19. 10 2
      netbox/dcim/tables/devices.py
  20. 5 1
      netbox/dcim/tables/devicetypes.py
  21. 4 1
      netbox/dcim/tables/racks.py
  22. 14 3
      netbox/dcim/tables/sites.py
  23. 21 0
      netbox/dcim/tests/test_views.py
  24. 8 9
      netbox/ipam/api/serializers.py
  25. 3 3
      netbox/ipam/api/views.py
  26. 3 3
      netbox/ipam/forms/bulk_edit.py
  27. 16 4
      netbox/ipam/forms/models.py
  28. 30 0
      netbox/ipam/migrations/0051_extend_tag_support.py
  29. 2 2
      netbox/ipam/models/ip.py
  30. 1 1
      netbox/ipam/models/vlans.py
  31. 8 2
      netbox/ipam/tables/ip.py
  32. 4 1
      netbox/ipam/tables/vlans.py
  33. 9 0
      netbox/ipam/tests/test_views.py
  34. 2 9
      netbox/netbox/api/serializers.py
  35. 1 0
      netbox/netbox/graphql/types.py
  36. 15 6
      netbox/netbox/models.py
  37. 7 7
      netbox/tenancy/api/serializers.py
  38. 5 9
      netbox/tenancy/api/views.py
  39. 3 3
      netbox/tenancy/forms/bulk_edit.py
  40. 15 3
      netbox/tenancy/forms/models.py
  41. 30 0
      netbox/tenancy/migrations/0004_extend_tag_support.py
  42. 3 3
      netbox/tenancy/models.py
  43. 8 2
      netbox/tenancy/tables.py
  44. 9 0
      netbox/tenancy/tests/test_views.py
  45. 5 5
      netbox/virtualization/api/serializers.py
  46. 2 2
      netbox/virtualization/api/views.py
  47. 2 2
      netbox/virtualization/forms/bulk_edit.py
  48. 14 6
      netbox/virtualization/forms/models.py
  49. 25 0
      netbox/virtualization/migrations/0025_extend_tag_support.py
  50. 2 2
      netbox/virtualization/models.py
  51. 8 2
      netbox/virtualization/tables.py
  52. 6 0
      netbox/virtualization/tests/test_views.py

+ 2 - 2
docs/development/models.md

@@ -19,8 +19,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
 | Type               | Change Logging   | Webhooks         | Custom Fields    | Export Templates | Tags             | Journaling       | Nesting          |
 | Type               | Change Logging   | Webhooks         | Custom Fields    | Export Templates | Tags             | Journaling       | Nesting          |
 | ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- |
 | ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- |
 | Primary            | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  |
 | Primary            | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  |
-| Organizational     | :material-check: | :material-check: | :material-check: | :material-check: |                  |                  |                  |
-| Nested Group       | :material-check: | :material-check: | :material-check: | :material-check: |                  |                  | :material-check: |
+| Organizational     | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  |                  |
+| Nested Group       | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  | :material-check: |
 | Component          | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  |                  |
 | Component          | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  |                  |
 | Component Template | :material-check: | :material-check: | :material-check: |                  |                  |                  |                  |
 | Component Template | :material-check: | :material-check: | :material-check: |                  |                  |                  |                  |
 
 

+ 0 - 3
docs/models/extras/tag.md

@@ -15,6 +15,3 @@ The `tag` filter can be specified multiple times to match only objects which hav
 ```no-highlight
 ```no-highlight
 GET /api/dcim/devices/?tag=monitored&tag=deprecated
 GET /api/dcim/devices/?tag=monitored&tag=deprecated
 ```
 ```
-
-!!! note
-    Tags have changed substantially in NetBox v2.9. They are no longer created on-demand when editing an object, and their representation in the REST API now includes a complete depiction of the tag rather than only its label.

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

@@ -5,9 +5,7 @@ from circuits.models import *
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
 from dcim.api.serializers import CableTerminationSerializer
 from dcim.api.serializers import CableTerminationSerializer
 from netbox.api import ChoiceField
 from netbox.api import ChoiceField
-from netbox.api.serializers import (
-    OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
-)
+from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from .nested_serializers import *
 from .nested_serializers import *
 
 
@@ -48,14 +46,14 @@ class ProviderNetworkSerializer(PrimaryModelSerializer):
 # Circuits
 # Circuits
 #
 #
 
 
-class CircuitTypeSerializer(OrganizationalModelSerializer):
+class CircuitTypeSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
     circuit_count = serializers.IntegerField(read_only=True)
     circuit_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
             'circuit_count',
             'circuit_count',
         ]
         ]
 
 

+ 1 - 1
netbox/circuits/api/views.py

@@ -34,7 +34,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class CircuitTypeViewSet(CustomFieldModelViewSet):
 class CircuitTypeViewSet(CustomFieldModelViewSet):
-    queryset = CircuitType.objects.annotate(
+    queryset = CircuitType.objects.prefetch_related('tags').annotate(
         circuit_count=count_related(Circuit, 'type')
         circuit_count=count_related(Circuit, 'type')
     )
     )
     serializer_class = serializers.CircuitTypeSerializer
     serializer_class = serializers.CircuitTypeSerializer

+ 1 - 1
netbox/circuits/forms/bulk_edit.py

@@ -79,7 +79,7 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
         ]
         ]
 
 
 
 
-class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class CircuitTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput

+ 5 - 1
netbox/circuits/forms/models.py

@@ -75,11 +75,15 @@ class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
 
 
 class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
 class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
         fields = [
         fields = [
-            'name', 'slug', 'description',
+            'name', 'slug', 'description', 'tags',
         ]
         ]
 
 
 
 

+ 20 - 0
netbox/circuits/migrations/0003_extend_tag_support.py

@@ -0,0 +1,20 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0062_clear_secrets_changelog'),
+        ('circuits', '0002_squashed_0029'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuittype',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 1 - 1
netbox/circuits/models.py

@@ -128,7 +128,7 @@ class ProviderNetwork(PrimaryModel):
         return reverse('circuits:providernetwork', args=[self.pk])
         return reverse('circuits:providernetwork', args=[self.pk])
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class CircuitType(OrganizationalModel):
 class CircuitType(OrganizationalModel):
     """
     """
     Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
     Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named

+ 4 - 1
netbox/circuits/tables.py

@@ -82,6 +82,9 @@ class CircuitTypeTable(BaseTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    tags = TagColumn(
+        url_name='circuits:circuittype_list'
+    )
     circuit_count = tables.Column(
     circuit_count = tables.Column(
         verbose_name='Circuits'
         verbose_name='Circuits'
     )
     )
@@ -89,7 +92,7 @@ class CircuitTypeTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = CircuitType
         model = CircuitType
-        fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
+        fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions')
         default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
         default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
 
 
 
 

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

@@ -64,10 +64,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
             CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
         ])
         ])
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Circuit Type X',
             'name': 'Circuit Type X',
             'slug': 'circuit-type-x',
             'slug': 'circuit-type-x',
             'description': 'A new circuit type',
             'description': 'A new circuit type',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (

+ 16 - 17
netbox/dcim/api/serializers.py

@@ -11,8 +11,7 @@ from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import (
 from netbox.api.serializers import (
-    NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer,
-    WritableNestedSerializer,
+    NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
 )
 )
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from users.api.nested_serializers import NestedUserSerializer
 from users.api.nested_serializers import NestedUserSerializer
@@ -87,8 +86,8 @@ class RegionSerializer(NestedGroupModelSerializer):
     class Meta:
     class Meta:
         model = Region
         model = Region
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
-            'site_count', '_depth',
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'site_count', '_depth',
         ]
         ]
 
 
 
 
@@ -100,8 +99,8 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
     class Meta:
     class Meta:
         model = SiteGroup
         model = SiteGroup
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
-            'site_count', '_depth',
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'site_count', '_depth',
         ]
         ]
 
 
 
 
@@ -144,20 +143,20 @@ class LocationSerializer(NestedGroupModelSerializer):
     class Meta:
     class Meta:
         model = Location
         model = Location
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'custom_fields',
+            'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields',
             'created', 'last_updated', 'rack_count', 'device_count', '_depth',
             'created', 'last_updated', 'rack_count', 'device_count', '_depth',
         ]
         ]
 
 
 
 
-class RackRoleSerializer(OrganizationalModelSerializer):
+class RackRoleSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
     rack_count = serializers.IntegerField(read_only=True)
     rack_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated',
-            'rack_count',
+            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'rack_count',
         ]
         ]
 
 
 
 
@@ -254,7 +253,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
 # Device types
 # Device types
 #
 #
 
 
-class ManufacturerSerializer(OrganizationalModelSerializer):
+class ManufacturerSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
     devicetype_count = serializers.IntegerField(read_only=True)
     devicetype_count = serializers.IntegerField(read_only=True)
     inventoryitem_count = serializers.IntegerField(read_only=True)
     inventoryitem_count = serializers.IntegerField(read_only=True)
@@ -263,7 +262,7 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
             'devicetype_count', 'inventoryitem_count', 'platform_count',
             'devicetype_count', 'inventoryitem_count', 'platform_count',
         ]
         ]
 
 
@@ -411,7 +410,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
 # Devices
 # Devices
 #
 #
 
 
-class DeviceRoleSerializer(OrganizationalModelSerializer):
+class DeviceRoleSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
     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)
@@ -419,12 +418,12 @@ class DeviceRoleSerializer(OrganizationalModelSerializer):
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created',
-            'last_updated', 'device_count', 'virtualmachine_count',
+            'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields',
+            'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
         ]
 
 
 
 
-class PlatformSerializer(OrganizationalModelSerializer):
+class PlatformSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
     manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
     manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
@@ -434,7 +433,7 @@ class PlatformSerializer(OrganizationalModelSerializer):
         model = Platform
         model = Platform
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
             'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
-            'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
+            'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
         ]
 
 
 
 

+ 7 - 7
netbox/dcim/api/views.py

@@ -110,7 +110,7 @@ class RegionViewSet(CustomFieldModelViewSet):
         'region',
         'region',
         'site_count',
         'site_count',
         cumulative=True
         cumulative=True
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.RegionSerializer
     serializer_class = serializers.RegionSerializer
     filterset_class = filtersets.RegionFilterSet
     filterset_class = filtersets.RegionFilterSet
 
 
@@ -126,7 +126,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet):
         'group',
         'group',
         'site_count',
         'site_count',
         cumulative=True
         cumulative=True
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.SiteGroupSerializer
     serializer_class = serializers.SiteGroupSerializer
     filterset_class = filtersets.SiteGroupFilterSet
     filterset_class = filtersets.SiteGroupFilterSet
 
 
@@ -167,7 +167,7 @@ class LocationViewSet(CustomFieldModelViewSet):
         'location',
         'location',
         'rack_count',
         'rack_count',
         cumulative=True
         cumulative=True
-    ).prefetch_related('site')
+    ).prefetch_related('site', 'tags')
     serializer_class = serializers.LocationSerializer
     serializer_class = serializers.LocationSerializer
     filterset_class = filtersets.LocationFilterSet
     filterset_class = filtersets.LocationFilterSet
 
 
@@ -177,7 +177,7 @@ class LocationViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class RackRoleViewSet(CustomFieldModelViewSet):
 class RackRoleViewSet(CustomFieldModelViewSet):
-    queryset = RackRole.objects.annotate(
+    queryset = RackRole.objects.prefetch_related('tags').annotate(
         rack_count=count_related(Rack, 'role')
         rack_count=count_related(Rack, 'role')
     )
     )
     serializer_class = serializers.RackRoleSerializer
     serializer_class = serializers.RackRoleSerializer
@@ -261,7 +261,7 @@ class RackReservationViewSet(ModelViewSet):
 #
 #
 
 
 class ManufacturerViewSet(CustomFieldModelViewSet):
 class ManufacturerViewSet(CustomFieldModelViewSet):
-    queryset = Manufacturer.objects.annotate(
+    queryset = Manufacturer.objects.prefetch_related('tags').annotate(
         devicetype_count=count_related(DeviceType, 'manufacturer'),
         devicetype_count=count_related(DeviceType, 'manufacturer'),
         inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
         inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
         platform_count=count_related(Platform, 'manufacturer')
         platform_count=count_related(Platform, 'manufacturer')
@@ -340,7 +340,7 @@ class DeviceBayTemplateViewSet(ModelViewSet):
 #
 #
 
 
 class DeviceRoleViewSet(CustomFieldModelViewSet):
 class DeviceRoleViewSet(CustomFieldModelViewSet):
-    queryset = DeviceRole.objects.annotate(
+    queryset = DeviceRole.objects.prefetch_related('tags').annotate(
         device_count=count_related(Device, 'device_role'),
         device_count=count_related(Device, 'device_role'),
         virtualmachine_count=count_related(VirtualMachine, 'role')
         virtualmachine_count=count_related(VirtualMachine, 'role')
     )
     )
@@ -353,7 +353,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class PlatformViewSet(CustomFieldModelViewSet):
 class PlatformViewSet(CustomFieldModelViewSet):
-    queryset = Platform.objects.annotate(
+    queryset = Platform.objects.prefetch_related('tags').annotate(
         device_count=count_related(Device, 'platform'),
         device_count=count_related(Device, 'platform'),
         virtualmachine_count=count_related(VirtualMachine, 'platform')
         virtualmachine_count=count_related(VirtualMachine, 'platform')
     )
     )

+ 7 - 7
netbox/dcim/forms/bulk_edit.py

@@ -51,7 +51,7 @@ __all__ = (
 )
 )
 
 
 
 
-class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class RegionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -69,7 +69,7 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['parent', 'description']
         nullable_fields = ['parent', 'description']
 
 
 
 
-class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -132,7 +132,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
         ]
         ]
 
 
 
 
-class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Location.objects.all(),
         queryset=Location.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -161,7 +161,7 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['parent', 'tenant', 'description']
         nullable_fields = ['parent', 'tenant', 'description']
 
 
 
 
-class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=RackRole.objects.all(),
         queryset=RackRole.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -303,7 +303,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
         nullable_fields = []
         nullable_fields = []
 
 
 
 
-class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -345,7 +345,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
         nullable_fields = ['airflow']
         nullable_fields = ['airflow']
 
 
 
 
-class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -367,7 +367,7 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['color', 'description']
         nullable_fields = ['color', 'description']
 
 
 
 
-class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput

+ 36 - 8
netbox/dcim/forms/models.py

@@ -70,11 +70,15 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm):
         required=False
         required=False
     )
     )
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = Region
         model = Region
         fields = (
         fields = (
-            'parent', 'name', 'slug', 'description',
+            'parent', 'name', 'slug', 'description', 'tags',
         )
         )
 
 
 
 
@@ -84,11 +88,15 @@ class SiteGroupForm(BootstrapMixin, CustomFieldModelForm):
         required=False
         required=False
     )
     )
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = SiteGroup
         model = SiteGroup
         fields = (
         fields = (
-            'parent', 'name', 'slug', 'description',
+            'parent', 'name', 'slug', 'description', 'tags',
         )
         )
 
 
 
 
@@ -187,15 +195,19 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         }
         }
     )
     )
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = Location
         model = Location
         fields = (
         fields = (
-            'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant',
+            'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
         )
         )
         fieldsets = (
         fieldsets = (
             ('Location', (
             ('Location', (
-                'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description',
+                'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
             )),
             )),
             ('Tenancy', ('tenant_group', 'tenant')),
             ('Tenancy', ('tenant_group', 'tenant')),
         )
         )
@@ -203,11 +215,15 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
 class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
         fields = [
         fields = [
-            'name', 'slug', 'color', 'description',
+            'name', 'slug', 'color', 'description', 'tags',
         ]
         ]
 
 
 
 
@@ -343,11 +359,15 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
 class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
         fields = [
         fields = [
-            'name', 'slug', 'description',
+            'name', 'slug', 'description', 'tags',
         ]
         ]
 
 
 
 
@@ -392,11 +412,15 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
 
 
 class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
 class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = [
         fields = [
-            'name', 'slug', 'color', 'vm_role', 'description',
+            'name', 'slug', 'color', 'vm_role', 'description', 'tags',
         ]
         ]
 
 
 
 
@@ -408,11 +432,15 @@ class PlatformForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField(
     slug = SlugField(
         max_length=64
         max_length=64
     )
     )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
         fields = [
         fields = [
-            'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
+            'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'napalm_args': SmallTextarea(),
             'napalm_args': SmallTextarea(),

+ 50 - 0
netbox/dcim/migrations/0138_extend_tag_support.py

@@ -0,0 +1,50 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0062_clear_secrets_changelog'),
+        ('dcim', '0137_relax_uniqueness_constraints'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='devicerole',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='location',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='manufacturer',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='platform',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='rackrole',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='region',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='sitegroup',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 3 - 3
netbox/dcim/models/devices.py

@@ -36,7 +36,7 @@ __all__ = (
 # Device Types
 # Device Types
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Manufacturer(OrganizationalModel):
 class Manufacturer(OrganizationalModel):
     """
     """
     A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
     A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@@ -351,7 +351,7 @@ class DeviceType(PrimaryModel):
 # Devices
 # Devices
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class DeviceRole(OrganizationalModel):
 class DeviceRole(OrganizationalModel):
     """
     """
     Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
     Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
@@ -391,7 +391,7 @@ class DeviceRole(OrganizationalModel):
         return reverse('dcim:devicerole', args=[self.pk])
         return reverse('dcim:devicerole', args=[self.pk])
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Platform(OrganizationalModel):
 class Platform(OrganizationalModel):
     """
     """
     Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
     Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".

+ 1 - 1
netbox/dcim/models/racks.py

@@ -35,7 +35,7 @@ __all__ = (
 # Racks
 # Racks
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class RackRole(OrganizationalModel):
 class RackRole(OrganizationalModel):
     """
     """
     Racks can be organized by functional role, similar to Devices.
     Racks can be organized by functional role, similar to Devices.

+ 3 - 3
netbox/dcim/models/sites.py

@@ -25,7 +25,7 @@ __all__ = (
 # Regions
 # Regions
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Region(NestedGroupModel):
 class Region(NestedGroupModel):
     """
     """
     A region represents a geographic collection of sites. For example, you might create regions representing countries,
     A region represents a geographic collection of sites. For example, you might create regions representing countries,
@@ -82,7 +82,7 @@ class Region(NestedGroupModel):
 # Site groups
 # Site groups
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class SiteGroup(NestedGroupModel):
 class SiteGroup(NestedGroupModel):
     """
     """
     A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
     A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
@@ -278,7 +278,7 @@ class Site(PrimaryModel):
 # Locations
 # Locations
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Location(NestedGroupModel):
 class Location(NestedGroupModel):
     """
     """
     A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
     A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a

+ 10 - 2
netbox/dcim/tables/devices.py

@@ -84,11 +84,16 @@ class DeviceRoleTable(BaseTable):
     )
     )
     color = ColorColumn()
     color = ColorColumn()
     vm_role = BooleanColumn()
     vm_role = BooleanColumn()
+    tags = TagColumn(
+        url_name='dcim:devicerole_list'
+    )
     actions = ButtonsColumn(DeviceRole)
     actions = ButtonsColumn(DeviceRole)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = DeviceRole
         model = DeviceRole
-        fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions')
+        fields = (
+            'pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions',
+        )
         default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
         default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
 
 
 
 
@@ -111,13 +116,16 @@ class PlatformTable(BaseTable):
         url_params={'platform_id': 'pk'},
         url_params={'platform_id': 'pk'},
         verbose_name='VMs'
         verbose_name='VMs'
     )
     )
+    tags = TagColumn(
+        url_name='dcim:platform_list'
+    )
     actions = ButtonsColumn(Platform)
     actions = ButtonsColumn(Platform)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Platform
         model = Platform
         fields = (
         fields = (
             'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
             'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
-            'description', 'actions',
+            'description', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
             'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',

+ 5 - 1
netbox/dcim/tables/devicetypes.py

@@ -41,12 +41,16 @@ class ManufacturerTable(BaseTable):
         verbose_name='Platforms'
         verbose_name='Platforms'
     )
     )
     slug = tables.Column()
     slug = tables.Column()
+    tags = TagColumn(
+        url_name='dcim:manufacturer_list'
+    )
     actions = ButtonsColumn(Manufacturer)
     actions = ButtonsColumn(Manufacturer)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Manufacturer
         model = Manufacturer
         fields = (
         fields = (
-            'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
+            'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'tags',
+            'actions',
         )
         )
 
 
 
 

+ 4 - 1
netbox/dcim/tables/racks.py

@@ -24,11 +24,14 @@ class RackRoleTable(BaseTable):
     name = tables.Column(linkify=True)
     name = tables.Column(linkify=True)
     rack_count = tables.Column(verbose_name='Racks')
     rack_count = tables.Column(verbose_name='Racks')
     color = ColorColumn()
     color = ColorColumn()
+    tags = TagColumn(
+        url_name='dcim:rackrole_list'
+    )
     actions = ButtonsColumn(RackRole)
     actions = ButtonsColumn(RackRole)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = RackRole
         model = RackRole
-        fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions')
+        fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions')
         default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
         default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
 
 
 
 

+ 14 - 3
netbox/dcim/tables/sites.py

@@ -29,11 +29,14 @@ class RegionTable(BaseTable):
         url_params={'region_id': 'pk'},
         url_params={'region_id': 'pk'},
         verbose_name='Sites'
         verbose_name='Sites'
     )
     )
+    tags = TagColumn(
+        url_name='dcim:region_list'
+    )
     actions = ButtonsColumn(Region)
     actions = ButtonsColumn(Region)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Region
         model = Region
-        fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
+        fields = ('pk', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
 
 
 
 
@@ -51,11 +54,14 @@ class SiteGroupTable(BaseTable):
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
         verbose_name='Sites'
         verbose_name='Sites'
     )
     )
+    tags = TagColumn(
+        url_name='dcim:sitegroup_list'
+    )
     actions = ButtonsColumn(SiteGroup)
     actions = ButtonsColumn(SiteGroup)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = SiteGroup
         model = SiteGroup
-        fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
+        fields = ('pk', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
 
 
 
 
@@ -114,6 +120,9 @@ class LocationTable(BaseTable):
         url_params={'location_id': 'pk'},
         url_params={'location_id': 'pk'},
         verbose_name='Devices'
         verbose_name='Devices'
     )
     )
+    tags = TagColumn(
+        url_name='dcim:location_list'
+    )
     actions = ButtonsColumn(
     actions = ButtonsColumn(
         model=Location,
         model=Location,
         prepend_template=LOCATION_ELEVATIONS
         prepend_template=LOCATION_ELEVATIONS
@@ -121,5 +130,7 @@ class LocationTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Location
         model = Location
-        fields = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'actions')
+        fields = (
+            'pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', 'actions',
+        )
         default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')

+ 21 - 0
netbox/dcim/tests/test_views.py

@@ -31,11 +31,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Region X',
             'name': 'Region X',
             'slug': 'region-x',
             'slug': 'region-x',
             'parent': regions[2].pk,
             'parent': regions[2].pk,
             'description': 'A new region',
             'description': 'A new region',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -65,11 +68,14 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         for sitegroup in sitegroups:
         for sitegroup in sitegroups:
             sitegroup.save()
             sitegroup.save()
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Site Group X',
             'name': 'Site Group X',
             'slug': 'site-group-x',
             'slug': 'site-group-x',
             'parent': sitegroups[2].pk,
             'parent': sitegroups[2].pk,
             'description': 'A new site group',
             'description': 'A new site group',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -169,12 +175,15 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         for location in locations:
         for location in locations:
             location.save()
             location.save()
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Location X',
             'name': 'Location X',
             'slug': 'location-x',
             'slug': 'location-x',
             'site': site.pk,
             'site': site.pk,
             'tenant': tenant.pk,
             'tenant': tenant.pk,
             'description': 'A new location',
             'description': 'A new location',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -201,11 +210,14 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             RackRole(name='Rack Role 3', slug='rack-role-3'),
             RackRole(name='Rack Role 3', slug='rack-role-3'),
         ])
         ])
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Rack Role X',
             'name': 'Rack Role X',
             'slug': 'rack-role-x',
             'slug': 'rack-role-x',
             'color': 'c0c0c0',
             'color': 'c0c0c0',
             'description': 'New role',
             'description': 'New role',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -368,10 +380,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
             Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
         ])
         ])
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Manufacturer X',
             'name': 'Manufacturer X',
             'slug': 'manufacturer-x',
             'slug': 'manufacturer-x',
             'description': 'A new manufacturer',
             'description': 'A new manufacturer',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -1034,12 +1049,15 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             DeviceRole(name='Device Role 3', slug='device-role-3'),
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         ])
         ])
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Devie Role X',
             'name': 'Devie Role X',
             'slug': 'device-role-x',
             'slug': 'device-role-x',
             'color': 'c0c0c0',
             'color': 'c0c0c0',
             'vm_role': False,
             'vm_role': False,
             'description': 'New device role',
             'description': 'New device role',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -1069,6 +1087,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer),
             Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer),
         ])
         ])
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Platform X',
             'name': 'Platform X',
             'slug': 'platform-x',
             'slug': 'platform-x',
@@ -1076,6 +1096,7 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             'napalm_driver': 'junos',
             'napalm_driver': 'junos',
             'napalm_args': None,
             'napalm_args': None,
             'description': 'A new platform',
             'description': 'A new platform',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (

+ 8 - 9
netbox/ipam/api/serializers.py

@@ -9,7 +9,6 @@ from ipam.choices import *
 from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
 from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
 from ipam.models import *
 from ipam.models import *
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
-from netbox.api.serializers import OrganizationalModelSerializer
 from netbox.api.serializers import PrimaryModelSerializer
 from netbox.api.serializers import PrimaryModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
@@ -66,14 +65,14 @@ class RouteTargetSerializer(PrimaryModelSerializer):
 # RIRs/aggregates
 # RIRs/aggregates
 #
 #
 
 
-class RIRSerializer(OrganizationalModelSerializer):
+class RIRSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
     aggregate_count = serializers.IntegerField(read_only=True)
     aggregate_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'custom_fields', 'created',
+            'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'aggregate_count',
             'last_updated', 'aggregate_count',
         ]
         ]
 
 
@@ -97,7 +96,7 @@ class AggregateSerializer(PrimaryModelSerializer):
 # VLANs
 # VLANs
 #
 #
 
 
-class RoleSerializer(OrganizationalModelSerializer):
+class RoleSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
     prefix_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
     vlan_count = serializers.IntegerField(read_only=True)
     vlan_count = serializers.IntegerField(read_only=True)
@@ -105,12 +104,12 @@ class RoleSerializer(OrganizationalModelSerializer):
     class Meta:
     class Meta:
         model = Role
         model = Role
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated',
-            'prefix_count', 'vlan_count',
+            'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'prefix_count', 'vlan_count',
         ]
         ]
 
 
 
 
-class VLANGroupSerializer(OrganizationalModelSerializer):
+class VLANGroupSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
     scope_type = ContentTypeField(
     scope_type = ContentTypeField(
         queryset=ContentType.objects.filter(
         queryset=ContentType.objects.filter(
@@ -126,8 +125,8 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields',
-            'created', 'last_updated', 'vlan_count',
+            'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'tags',
+            'custom_fields', 'created', 'last_updated', 'vlan_count',
         ]
         ]
         validators = []
         validators = []
 
 

+ 3 - 3
netbox/ipam/api/views.py

@@ -48,7 +48,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
 class RIRViewSet(CustomFieldModelViewSet):
 class RIRViewSet(CustomFieldModelViewSet):
     queryset = RIR.objects.annotate(
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
         aggregate_count=count_related(Aggregate, 'rir')
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.RIRSerializer
     serializer_class = serializers.RIRSerializer
     filterset_class = filtersets.RIRFilterSet
     filterset_class = filtersets.RIRFilterSet
 
 
@@ -71,7 +71,7 @@ class RoleViewSet(CustomFieldModelViewSet):
     queryset = Role.objects.annotate(
     queryset = Role.objects.annotate(
         prefix_count=count_related(Prefix, 'role'),
         prefix_count=count_related(Prefix, 'role'),
         vlan_count=count_related(VLAN, 'role')
         vlan_count=count_related(VLAN, 'role')
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.RoleSerializer
     serializer_class = serializers.RoleSerializer
     filterset_class = filtersets.RoleFilterSet
     filterset_class = filtersets.RoleFilterSet
 
 
@@ -126,7 +126,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
 class VLANGroupViewSet(CustomFieldModelViewSet):
 class VLANGroupViewSet(CustomFieldModelViewSet):
     queryset = VLANGroup.objects.annotate(
     queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
         vlan_count=count_related(VLAN, 'group')
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.VLANGroupSerializer
     serializer_class = serializers.VLANGroupSerializer
     filterset_class = filtersets.VLANGroupFilterSet
     filterset_class = filtersets.VLANGroupFilterSet
 
 

+ 3 - 3
netbox/ipam/forms/bulk_edit.py

@@ -71,7 +71,7 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
         ]
         ]
 
 
 
 
-class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -120,7 +120,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
         }
         }
 
 
 
 
-class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -280,7 +280,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
         ]
         ]
 
 
 
 
-class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput

+ 16 - 4
netbox/ipam/forms/models.py

@@ -82,11 +82,15 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 class RIRForm(BootstrapMixin, CustomFieldModelForm):
 class RIRForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
         fields = [
         fields = [
-            'name', 'slug', 'is_private', 'description',
+            'name', 'slug', 'is_private', 'description', 'tags',
         ]
         ]
 
 
 
 
@@ -120,11 +124,15 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
 class RoleForm(BootstrapMixin, CustomFieldModelForm):
 class RoleForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = Role
         model = Role
         fields = [
         fields = [
-            'name', 'slug', 'weight', 'description',
+            'name', 'slug', 'weight', 'description', 'tags',
         ]
         ]
 
 
 
 
@@ -530,15 +538,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
         }
         }
     )
     )
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
         fields = [
         fields = [
             'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
             'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
-            'clustergroup', 'cluster',
+            'clustergroup', 'cluster', 'tags',
         ]
         ]
         fieldsets = (
         fieldsets = (
-            ('VLAN Group', ('name', 'slug', 'description')),
+            ('VLAN Group', ('name', 'slug', 'description', 'tags')),
             ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
             ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
         )
         )
         widgets = {
         widgets = {

+ 30 - 0
netbox/ipam/migrations/0051_extend_tag_support.py

@@ -0,0 +1,30 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0062_clear_secrets_changelog'),
+        ('ipam', '0050_iprange'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='rir',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='role',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='vlangroup',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 2 - 2
netbox/ipam/models/ip.py

@@ -31,7 +31,7 @@ __all__ = (
 )
 )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class RIR(OrganizationalModel):
 class RIR(OrganizationalModel):
     """
     """
     A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
     A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
@@ -168,7 +168,7 @@ class Aggregate(PrimaryModel):
         return min(utilization, 100)
         return min(utilization, 100)
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Role(OrganizationalModel):
 class Role(OrganizationalModel):
     """
     """
     A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
     A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or

+ 1 - 1
netbox/ipam/models/vlans.py

@@ -21,7 +21,7 @@ __all__ = (
 )
 )
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class VLANGroup(OrganizationalModel):
 class VLANGroup(OrganizationalModel):
     """
     """
     A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
     A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.

+ 8 - 2
netbox/ipam/tables/ip.py

@@ -85,11 +85,14 @@ class RIRTable(BaseTable):
         url_params={'rir_id': 'pk'},
         url_params={'rir_id': 'pk'},
         verbose_name='Aggregates'
         verbose_name='Aggregates'
     )
     )
+    tags = TagColumn(
+        url_name='ipam:rir_list'
+    )
     actions = ButtonsColumn(RIR)
     actions = ButtonsColumn(RIR)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = RIR
         model = RIR
-        fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'actions')
+        fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
 
 
 
 
@@ -144,11 +147,14 @@ class RoleTable(BaseTable):
         url_params={'role_id': 'pk'},
         url_params={'role_id': 'pk'},
         verbose_name='VLANs'
         verbose_name='VLANs'
     )
     )
+    tags = TagColumn(
+        url_name='ipam:role_list'
+    )
     actions = ButtonsColumn(Role)
     actions = ButtonsColumn(Role)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Role
         model = Role
-        fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'actions')
+        fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions')
         default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
 
 
 
 

+ 4 - 1
netbox/ipam/tables/vlans.py

@@ -74,6 +74,9 @@ class VLANGroupTable(BaseTable):
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
         verbose_name='VLANs'
         verbose_name='VLANs'
     )
     )
+    tags = TagColumn(
+        url_name='ipam:vlangroup_list'
+    )
     actions = ButtonsColumn(
     actions = ButtonsColumn(
         model=VLANGroup,
         model=VLANGroup,
         prepend_template=VLANGROUP_ADD_VLAN
         prepend_template=VLANGROUP_ADD_VLAN
@@ -81,7 +84,7 @@ class VLANGroupTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VLANGroup
         model = VLANGroup
-        fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
+        fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
 
 
 
 

+ 9 - 0
netbox/ipam/tests/test_views.py

@@ -104,11 +104,14 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             RIR(name='RIR 3', slug='rir-3'),
             RIR(name='RIR 3', slug='rir-3'),
         ])
         ])
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'RIR X',
             'name': 'RIR X',
             'slug': 'rir-x',
             'slug': 'rir-x',
             'is_private': True,
             'is_private': True,
             'description': 'A new RIR',
             'description': 'A new RIR',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -177,11 +180,14 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             Role(name='Role 3', slug='role-3'),
             Role(name='Role 3', slug='role-3'),
         ])
         ])
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Role X',
             'name': 'Role X',
             'slug': 'role-x',
             'slug': 'role-x',
             'weight': 200,
             'weight': 200,
             'description': 'A new role',
             'description': 'A new role',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -384,10 +390,13 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]),
             VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]),
         ])
         ])
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'VLAN Group X',
             'name': 'VLAN Group X',
             'slug': 'vlan-group-x',
             'slug': 'vlan-group-x',
             'description': 'A new VLAN group',
             'description': 'A new VLAN group',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (

+ 2 - 9
netbox/netbox/api/serializers.py

@@ -147,13 +147,6 @@ class NestedTagSerializer(WritableNestedSerializer):
 # Base model serializers
 # Base model serializers
 #
 #
 
 
-class OrganizationalModelSerializer(CustomFieldModelSerializer):
-    """
-    Adds support for custom fields.
-    """
-    pass
-
-
 class PrimaryModelSerializer(CustomFieldModelSerializer):
 class PrimaryModelSerializer(CustomFieldModelSerializer):
     """
     """
     Adds support for custom fields and tags.
     Adds support for custom fields and tags.
@@ -189,9 +182,9 @@ class PrimaryModelSerializer(CustomFieldModelSerializer):
         return instance
         return instance
 
 
 
 
-class NestedGroupModelSerializer(CustomFieldModelSerializer):
+class NestedGroupModelSerializer(PrimaryModelSerializer):
     """
     """
-    Extends OrganizationalModelSerializer to include MPTT support.
+    Extends PrimaryModelSerializer to include MPTT support.
     """
     """
     _depth = serializers.IntegerField(source='level', read_only=True)
     _depth = serializers.IntegerField(source='level', read_only=True)
 
 

+ 1 - 0
netbox/netbox/graphql/types.py

@@ -41,6 +41,7 @@ class ObjectType(
 class OrganizationalObjectType(
 class OrganizationalObjectType(
     ChangelogMixin,
     ChangelogMixin,
     CustomFieldsMixin,
     CustomFieldsMixin,
+    TagsMixin,
     BaseObjectType
     BaseObjectType
 ):
 ):
     """
     """

+ 15 - 6
netbox/netbox/models.py

@@ -143,6 +143,18 @@ class CustomValidationMixin(models.Model):
         post_clean.send(sender=self.__class__, instance=self)
         post_clean.send(sender=self.__class__, instance=self)
 
 
 
 
+class TagsMixin(models.Model):
+    """
+    Enable the assignment of Tags.
+    """
+    tags = TaggableManager(
+        through='extras.TaggedItem'
+    )
+
+    class Meta:
+        abstract = True
+
+
 #
 #
 # Base model classes
 # Base model classes
 
 
@@ -166,7 +178,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel):
         abstract = True
         abstract = True
 
 
 
 
-class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel):
+class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel):
     """
     """
     Primary models represent real objects within the infrastructure being modeled.
     Primary models represent real objects within the infrastructure being modeled.
     """
     """
@@ -175,15 +187,12 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin,
         object_id_field='assigned_object_id',
         object_id_field='assigned_object_id',
         content_type_field='assigned_object_type'
         content_type_field='assigned_object_type'
     )
     )
-    tags = TaggableManager(
-        through='extras.TaggedItem'
-    )
 
 
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
 
 
-class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel, MPTTModel):
+class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel):
     """
     """
     Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
     Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
     recursively using MPTT. Within each parent, each child instance must have a unique name.
     recursively using MPTT. Within each parent, each child instance must have a unique name.
@@ -225,7 +234,7 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMi
             })
             })
 
 
 
 
-class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel):
+class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel):
     """
     """
     Organizational models are those which are used solely to categorize and qualify other objects, and do not convey
     Organizational models are those which are used solely to categorize and qualify other objects, and do not convey
     any real information about the infrastructure being modeled (for example, functional device roles). Organizational
     any real information about the infrastructure being modeled (for example, functional device roles). Organizational

+ 7 - 7
netbox/tenancy/api/serializers.py

@@ -2,7 +2,7 @@ from django.contrib.auth.models import ContentType
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from netbox.api import ChoiceField, ContentTypeField
 from netbox.api import ChoiceField, ContentTypeField
-from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer
+from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
 from tenancy.choices import ContactPriorityChoices
 from tenancy.choices import ContactPriorityChoices
 from tenancy.models import *
 from tenancy.models import *
 from .nested_serializers import *
 from .nested_serializers import *
@@ -20,8 +20,8 @@ class TenantGroupSerializer(NestedGroupModelSerializer):
     class Meta:
     class Meta:
         model = TenantGroup
         model = TenantGroup
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
-            'tenant_count', '_depth',
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'tenant_count', '_depth',
         ]
         ]
 
 
 
 
@@ -60,18 +60,18 @@ class ContactGroupSerializer(NestedGroupModelSerializer):
     class Meta:
     class Meta:
         model = ContactGroup
         model = ContactGroup
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
-            'contact_count', '_depth',
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'contact_count', '_depth',
         ]
         ]
 
 
 
 
-class ContactRoleSerializer(OrganizationalModelSerializer):
+class ContactRoleSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail')
     url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail')
 
 
     class Meta:
     class Meta:
         model = ContactRole
         model = ContactRole
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
 
 
 
 

+ 5 - 9
netbox/tenancy/api/views.py

@@ -30,7 +30,7 @@ class TenantGroupViewSet(CustomFieldModelViewSet):
         'group',
         'group',
         'tenant_count',
         'tenant_count',
         cumulative=True
         cumulative=True
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.TenantGroupSerializer
     serializer_class = serializers.TenantGroupSerializer
     filterset_class = filtersets.TenantGroupFilterSet
     filterset_class = filtersets.TenantGroupFilterSet
 
 
@@ -64,28 +64,24 @@ class ContactGroupViewSet(CustomFieldModelViewSet):
         'group',
         'group',
         'contact_count',
         'contact_count',
         cumulative=True
         cumulative=True
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.ContactGroupSerializer
     serializer_class = serializers.ContactGroupSerializer
     filterset_class = filtersets.ContactGroupFilterSet
     filterset_class = filtersets.ContactGroupFilterSet
 
 
 
 
 class ContactRoleViewSet(CustomFieldModelViewSet):
 class ContactRoleViewSet(CustomFieldModelViewSet):
-    queryset = ContactRole.objects.all()
+    queryset = ContactRole.objects.prefetch_related('tags')
     serializer_class = serializers.ContactRoleSerializer
     serializer_class = serializers.ContactRoleSerializer
     filterset_class = filtersets.ContactRoleFilterSet
     filterset_class = filtersets.ContactRoleFilterSet
 
 
 
 
 class ContactViewSet(CustomFieldModelViewSet):
 class ContactViewSet(CustomFieldModelViewSet):
-    queryset = Contact.objects.prefetch_related(
-        'group', 'tags'
-    )
+    queryset = Contact.objects.prefetch_related('group', 'tags')
     serializer_class = serializers.ContactSerializer
     serializer_class = serializers.ContactSerializer
     filterset_class = filtersets.ContactFilterSet
     filterset_class = filtersets.ContactFilterSet
 
 
 
 
 class ContactAssignmentViewSet(CustomFieldModelViewSet):
 class ContactAssignmentViewSet(CustomFieldModelViewSet):
-    queryset = ContactAssignment.objects.prefetch_related(
-        'contact', 'role'
-    )
+    queryset = ContactAssignment.objects.prefetch_related('contact', 'role')
     serializer_class = serializers.ContactAssignmentSerializer
     serializer_class = serializers.ContactAssignmentSerializer
     filterset_class = filtersets.ContactAssignmentFilterSet
     filterset_class = filtersets.ContactAssignmentFilterSet

+ 3 - 3
netbox/tenancy/forms/bulk_edit.py

@@ -17,7 +17,7 @@ __all__ = (
 # Tenants
 # Tenants
 #
 #
 
 
-class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class TenantGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -55,7 +55,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
 # Contacts
 # Contacts
 #
 #
 
 
-class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ContactGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -73,7 +73,7 @@ class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['parent', 'description']
         nullable_fields = ['parent', 'description']
 
 
 
 
-class ContactRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ContactRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ContactRole.objects.all(),
         queryset=ContactRole.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput

+ 15 - 3
netbox/tenancy/forms/models.py

@@ -28,11 +28,15 @@ class TenantGroupForm(BootstrapMixin, CustomFieldModelForm):
         required=False
         required=False
     )
     )
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = TenantGroup
         model = TenantGroup
         fields = [
         fields = [
-            'parent', 'name', 'slug', 'description',
+            'parent', 'name', 'slug', 'description', 'tags',
         ]
         ]
 
 
 
 
@@ -68,18 +72,26 @@ class ContactGroupForm(BootstrapMixin, CustomFieldModelForm):
         required=False
         required=False
     )
     )
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = ContactGroup
         model = ContactGroup
-        fields = ['parent', 'name', 'slug', 'description']
+        fields = ('parent', 'name', 'slug', 'description', 'tags')
 
 
 
 
 class ContactRoleForm(BootstrapMixin, CustomFieldModelForm):
 class ContactRoleForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = ContactRole
         model = ContactRole
-        fields = ['name', 'slug', 'description']
+        fields = ('name', 'slug', 'description', 'tags')
 
 
 
 
 class ContactForm(BootstrapMixin, CustomFieldModelForm):
 class ContactForm(BootstrapMixin, CustomFieldModelForm):

+ 30 - 0
netbox/tenancy/migrations/0004_extend_tag_support.py

@@ -0,0 +1,30 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0062_clear_secrets_changelog'),
+        ('tenancy', '0003_contacts'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='contactgroup',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='contactrole',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='tenantgroup',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 3 - 3
netbox/tenancy/models.py

@@ -24,7 +24,7 @@ __all__ = (
 # Tenants
 # Tenants
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class TenantGroup(NestedGroupModel):
 class TenantGroup(NestedGroupModel):
     """
     """
     An arbitrary collection of Tenants.
     An arbitrary collection of Tenants.
@@ -111,7 +111,7 @@ class Tenant(PrimaryModel):
 # Contacts
 # Contacts
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class ContactGroup(NestedGroupModel):
 class ContactGroup(NestedGroupModel):
     """
     """
     An arbitrary collection of Contacts.
     An arbitrary collection of Contacts.
@@ -145,7 +145,7 @@ class ContactGroup(NestedGroupModel):
         return reverse('tenancy:contactgroup', args=[self.pk])
         return reverse('tenancy:contactgroup', args=[self.pk])
 
 
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class ContactRole(OrganizationalModel):
 class ContactRole(OrganizationalModel):
     """
     """
     Functional role for a Contact assigned to an object.
     Functional role for a Contact assigned to an object.

+ 8 - 2
netbox/tenancy/tables.py

@@ -55,11 +55,14 @@ class TenantGroupTable(BaseTable):
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
         verbose_name='Tenants'
         verbose_name='Tenants'
     )
     )
+    tags = TagColumn(
+        url_name='tenancy:tenantgroup_list'
+    )
     actions = ButtonsColumn(TenantGroup)
     actions = ButtonsColumn(TenantGroup)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = TenantGroup
         model = TenantGroup
-        fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'actions')
+        fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions')
         default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
 
 
 
 
@@ -96,11 +99,14 @@ class ContactGroupTable(BaseTable):
         url_params={'role_id': 'pk'},
         url_params={'role_id': 'pk'},
         verbose_name='Contacts'
         verbose_name='Contacts'
     )
     )
+    tags = TagColumn(
+        url_name='tenancy:contactgroup_list'
+    )
     actions = ButtonsColumn(ContactGroup)
     actions = ButtonsColumn(ContactGroup)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ContactGroup
         model = ContactGroup
-        fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'actions')
+        fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions')
         default_columns = ('pk', 'name', 'contact_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'contact_count', 'description', 'actions')
 
 
 
 

+ 9 - 0
netbox/tenancy/tests/test_views.py

@@ -16,10 +16,13 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         for tenanantgroup in tenant_groups:
         for tenanantgroup in tenant_groups:
             tenanantgroup.save()
             tenanantgroup.save()
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Tenant Group X',
             'name': 'Tenant Group X',
             'slug': 'tenant-group-x',
             'slug': 'tenant-group-x',
             'description': 'A new tenant group',
             'description': 'A new tenant group',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -90,10 +93,13 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         for tenanantgroup in contact_groups:
         for tenanantgroup in contact_groups:
             tenanantgroup.save()
             tenanantgroup.save()
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Contact Group X',
             'name': 'Contact Group X',
             'slug': 'contact-group-x',
             'slug': 'contact-group-x',
             'description': 'A new contact group',
             'description': 'A new contact group',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -120,10 +126,13 @@ class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             ContactRole(name='Contact Role 3', slug='contact-role-3'),
             ContactRole(name='Contact Role 3', slug='contact-role-3'),
         ])
         ])
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Devie Role X',
             'name': 'Devie Role X',
             'slug': 'contact-role-x',
             'slug': 'contact-role-x',
             'description': 'New contact role',
             'description': 'New contact role',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (

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

@@ -6,7 +6,7 @@ from dcim.choices import InterfaceModeChoices
 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 netbox.api import ChoiceField, SerializedPKRelatedField
 from netbox.api import ChoiceField, SerializedPKRelatedField
-from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
+from netbox.api.serializers import PrimaryModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -17,26 +17,26 @@ from .nested_serializers import *
 # Clusters
 # Clusters
 #
 #
 
 
-class ClusterTypeSerializer(OrganizationalModelSerializer):
+class ClusterTypeSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
     cluster_count = serializers.IntegerField(read_only=True)
     cluster_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = ClusterType
         model = ClusterType
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
             'cluster_count',
             'cluster_count',
         ]
         ]
 
 
 
 
-class ClusterGroupSerializer(OrganizationalModelSerializer):
+class ClusterGroupSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
     cluster_count = serializers.IntegerField(read_only=True)
     cluster_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
             'cluster_count',
             'cluster_count',
         ]
         ]
 
 

+ 2 - 2
netbox/virtualization/api/views.py

@@ -23,7 +23,7 @@ class VirtualizationRootView(APIRootView):
 class ClusterTypeViewSet(CustomFieldModelViewSet):
 class ClusterTypeViewSet(CustomFieldModelViewSet):
     queryset = ClusterType.objects.annotate(
     queryset = ClusterType.objects.annotate(
         cluster_count=count_related(Cluster, 'type')
         cluster_count=count_related(Cluster, 'type')
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.ClusterTypeSerializer
     serializer_class = serializers.ClusterTypeSerializer
     filterset_class = filtersets.ClusterTypeFilterSet
     filterset_class = filtersets.ClusterTypeFilterSet
 
 
@@ -31,7 +31,7 @@ class ClusterTypeViewSet(CustomFieldModelViewSet):
 class ClusterGroupViewSet(CustomFieldModelViewSet):
 class ClusterGroupViewSet(CustomFieldModelViewSet):
     queryset = ClusterGroup.objects.annotate(
     queryset = ClusterGroup.objects.annotate(
         cluster_count=count_related(Cluster, 'group')
         cluster_count=count_related(Cluster, 'group')
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.ClusterGroupSerializer
     serializer_class = serializers.ClusterGroupSerializer
     filterset_class = filtersets.ClusterGroupFilterSet
     filterset_class = filtersets.ClusterGroupFilterSet
 
 

+ 2 - 2
netbox/virtualization/forms/bulk_edit.py

@@ -23,7 +23,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ClusterTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
@@ -37,7 +37,7 @@ class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['description']
         nullable_fields = ['description']
 
 
 
 
-class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ClusterGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput

+ 14 - 6
netbox/virtualization/forms/models.py

@@ -28,22 +28,30 @@ __all__ = (
 
 
 class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm):
 class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = ClusterType
         model = ClusterType
-        fields = [
-            'name', 'slug', 'description',
-        ]
+        fields = (
+            'name', 'slug', 'description', 'tags',
+        )
 
 
 
 
 class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm):
 class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup
-        fields = [
-            'name', 'slug', 'description',
-        ]
+        fields = (
+            'name', 'slug', 'description', 'tags',
+        )
 
 
 
 
 class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):

+ 25 - 0
netbox/virtualization/migrations/0025_extend_tag_support.py

@@ -0,0 +1,25 @@
+# Generated by Django 3.2.8 on 2021-10-21 14:50
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0062_clear_secrets_changelog'),
+        ('virtualization', '0024_cluster_relax_uniqueness'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='clustergroup',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='clustertype',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 2 - 2
netbox/virtualization/models.py

@@ -30,7 +30,7 @@ __all__ = (
 # Cluster types
 # Cluster types
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class ClusterType(OrganizationalModel):
 class ClusterType(OrganizationalModel):
     """
     """
     A type of Cluster.
     A type of Cluster.
@@ -64,7 +64,7 @@ class ClusterType(OrganizationalModel):
 # Cluster groups
 # Cluster groups
 #
 #
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class ClusterGroup(OrganizationalModel):
 class ClusterGroup(OrganizationalModel):
     """
     """
     An organizational group of Clusters.
     An organizational group of Clusters.

+ 8 - 2
netbox/virtualization/tables.py

@@ -40,11 +40,14 @@ class ClusterTypeTable(BaseTable):
     cluster_count = tables.Column(
     cluster_count = tables.Column(
         verbose_name='Clusters'
         verbose_name='Clusters'
     )
     )
+    tags = TagColumn(
+        url_name='virtualization:clustertype_list'
+    )
     actions = ButtonsColumn(ClusterType)
     actions = ButtonsColumn(ClusterType)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ClusterType
         model = ClusterType
-        fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions')
+        fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
 
 
 
 
@@ -60,11 +63,14 @@ class ClusterGroupTable(BaseTable):
     cluster_count = tables.Column(
     cluster_count = tables.Column(
         verbose_name='Clusters'
         verbose_name='Clusters'
     )
     )
+    tags = TagColumn(
+        url_name='virtualization:clustergroup_list'
+    )
     actions = ButtonsColumn(ClusterGroup)
     actions = ButtonsColumn(ClusterGroup)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ClusterGroup
         model = ClusterGroup
-        fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions')
+        fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
 
 
 
 

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

@@ -22,10 +22,13 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
             ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
         ])
         ])
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Cluster Group X',
             'name': 'Cluster Group X',
             'slug': 'cluster-group-x',
             'slug': 'cluster-group-x',
             'description': 'A new cluster group',
             'description': 'A new cluster group',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -52,10 +55,13 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
             ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
         ])
         ])
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
         cls.form_data = {
             'name': 'Cluster Type X',
             'name': 'Cluster Type X',
             'slug': 'cluster-type-x',
             'slug': 'cluster-type-x',
             'description': 'A new cluster type',
             'description': 'A new cluster type',
+            'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (