Explorar el Código

Merge pull request #7610 from netbox-community/6497-organizational-model-tags

Closes #6297: Extend tag support to organizational models
Jeremy Stretch hace 4 años
padre
commit
001ab1d067
Se han modificado 100 ficheros con 500 adiciones y 192 borrados
  1. 2 2
      docs/development/models.md
  2. 0 3
      docs/models/extras/tag.md
  3. 18 0
      docs/release-notes/version-3.1.md
  4. 3 5
      netbox/circuits/api/serializers.py
  5. 1 1
      netbox/circuits/api/views.py
  6. 1 1
      netbox/circuits/forms/bulk_edit.py
  7. 5 1
      netbox/circuits/forms/models.py
  8. 20 0
      netbox/circuits/migrations/0003_extend_tag_support.py
  9. 1 1
      netbox/circuits/models.py
  10. 4 1
      netbox/circuits/tables.py
  11. 3 0
      netbox/circuits/tests/test_views.py
  12. 16 17
      netbox/dcim/api/serializers.py
  13. 7 7
      netbox/dcim/api/views.py
  14. 7 7
      netbox/dcim/forms/bulk_edit.py
  15. 36 8
      netbox/dcim/forms/models.py
  16. 50 0
      netbox/dcim/migrations/0138_extend_tag_support.py
  17. 3 3
      netbox/dcim/models/devices.py
  18. 1 1
      netbox/dcim/models/racks.py
  19. 3 3
      netbox/dcim/models/sites.py
  20. 10 2
      netbox/dcim/tables/devices.py
  21. 5 1
      netbox/dcim/tables/devicetypes.py
  22. 4 1
      netbox/dcim/tables/racks.py
  23. 14 3
      netbox/dcim/tables/sites.py
  24. 21 0
      netbox/dcim/tests/test_views.py
  25. 8 9
      netbox/ipam/api/serializers.py
  26. 3 3
      netbox/ipam/api/views.py
  27. 3 3
      netbox/ipam/forms/bulk_edit.py
  28. 16 4
      netbox/ipam/forms/models.py
  29. 30 0
      netbox/ipam/migrations/0051_extend_tag_support.py
  30. 2 2
      netbox/ipam/models/ip.py
  31. 1 1
      netbox/ipam/models/vlans.py
  32. 8 2
      netbox/ipam/tables/ip.py
  33. 4 1
      netbox/ipam/tables/vlans.py
  34. 9 0
      netbox/ipam/tests/test_views.py
  35. 2 9
      netbox/netbox/api/serializers.py
  36. 1 0
      netbox/netbox/graphql/types.py
  37. 15 6
      netbox/netbox/models.py
  38. 1 1
      netbox/templates/circuits/circuit.html
  39. 1 0
      netbox/templates/circuits/circuittype.html
  40. 1 1
      netbox/templates/circuits/provider.html
  41. 1 1
      netbox/templates/circuits/providernetwork.html
  42. 1 1
      netbox/templates/dcim/cable.html
  43. 1 1
      netbox/templates/dcim/consoleport.html
  44. 1 1
      netbox/templates/dcim/consoleserverport.html
  45. 1 1
      netbox/templates/dcim/device.html
  46. 1 1
      netbox/templates/dcim/devicebay.html
  47. 1 0
      netbox/templates/dcim/devicerole.html
  48. 1 1
      netbox/templates/dcim/devicetype.html
  49. 1 1
      netbox/templates/dcim/frontport.html
  50. 1 1
      netbox/templates/dcim/interface.html
  51. 1 1
      netbox/templates/dcim/inventoryitem.html
  52. 1 0
      netbox/templates/dcim/location.html
  53. 1 0
      netbox/templates/dcim/manufacturer.html
  54. 1 0
      netbox/templates/dcim/platform.html
  55. 1 1
      netbox/templates/dcim/powerfeed.html
  56. 1 1
      netbox/templates/dcim/poweroutlet.html
  57. 1 1
      netbox/templates/dcim/powerpanel.html
  58. 1 1
      netbox/templates/dcim/powerport.html
  59. 1 1
      netbox/templates/dcim/rack.html
  60. 1 1
      netbox/templates/dcim/rackreservation.html
  61. 1 0
      netbox/templates/dcim/rackrole.html
  62. 1 1
      netbox/templates/dcim/rearport.html
  63. 1 0
      netbox/templates/dcim/region.html
  64. 1 2
      netbox/templates/dcim/site.html
  65. 1 0
      netbox/templates/dcim/sitegroup.html
  66. 1 1
      netbox/templates/dcim/virtualchassis.html
  67. 9 6
      netbox/templates/inc/panels/tags.html
  68. 1 1
      netbox/templates/ipam/aggregate.html
  69. 1 1
      netbox/templates/ipam/ipaddress.html
  70. 1 1
      netbox/templates/ipam/iprange.html
  71. 1 1
      netbox/templates/ipam/prefix.html
  72. 1 0
      netbox/templates/ipam/rir.html
  73. 1 0
      netbox/templates/ipam/role.html
  74. 1 1
      netbox/templates/ipam/routetarget.html
  75. 1 1
      netbox/templates/ipam/service.html
  76. 1 1
      netbox/templates/ipam/vlan.html
  77. 1 0
      netbox/templates/ipam/vlangroup.html
  78. 1 1
      netbox/templates/ipam/vrf.html
  79. 1 1
      netbox/templates/tenancy/contact.html
  80. 1 0
      netbox/templates/tenancy/contactgroup.html
  81. 1 0
      netbox/templates/tenancy/contactrole.html
  82. 1 1
      netbox/templates/tenancy/tenant.html
  83. 1 0
      netbox/templates/tenancy/tenantgroup.html
  84. 1 1
      netbox/templates/virtualization/cluster.html
  85. 1 0
      netbox/templates/virtualization/clustergroup.html
  86. 1 0
      netbox/templates/virtualization/clustertype.html
  87. 1 1
      netbox/templates/virtualization/virtualmachine.html
  88. 2 2
      netbox/templates/virtualization/vminterface.html
  89. 7 7
      netbox/tenancy/api/serializers.py
  90. 5 9
      netbox/tenancy/api/views.py
  91. 3 3
      netbox/tenancy/forms/bulk_edit.py
  92. 15 3
      netbox/tenancy/forms/models.py
  93. 30 0
      netbox/tenancy/migrations/0004_extend_tag_support.py
  94. 3 3
      netbox/tenancy/models.py
  95. 8 2
      netbox/tenancy/tables.py
  96. 9 0
      netbox/tenancy/tests/test_views.py
  97. 5 5
      netbox/virtualization/api/serializers.py
  98. 2 2
      netbox/virtualization/api/views.py
  99. 2 2
      netbox/virtualization/forms/bulk_edit.py
  100. 14 6
      netbox/virtualization/forms/models.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          |
 | ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- |
 | 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 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
 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.

+ 18 - 0
docs/release-notes/version-3.1.md

@@ -20,6 +20,7 @@ When assigning a contact to an object, the user must select a predefined role (e
 * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces
 * [#1943](https://github.com/netbox-community/netbox/issues/1943) - Relax uniqueness constraint on cluster names
 * [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices
+* [#6497](https://github.com/netbox-community/netbox/issues/6497) - Extend tag support to organizational models
 * [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support
 * [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables
 * [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations
@@ -37,6 +38,23 @@ When assigning a contact to an object, the user must select a predefined role (e
     * `/api/tenancy/contact-groups/`
     * `/api/tenancy/contact-roles/`
     * `/api/tenancy/contacts/`
+* Added `tags` field to the following models:
+    * circuits.CircuitType
+    * dcim.DeviceRole
+    * dcim.Location
+    * dcim.Manufacturer
+    * dcim.Platform
+    * dcim.RackRole
+    * dcim.Region
+    * dcim.SiteGroup
+    * ipam.RIR
+    * ipam.Role
+    * ipam.VLANGroup
+    * tenancy.ContactGroup
+    * tenancy.ContactRole
+    * tenancy.TenantGroup
+    * virtualization.ClusterGroup
+    * virtualization.ClusterType
 * dcim.Cable
     * Added `tenant` field
 * dcim.Device

+ 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.serializers import CableTerminationSerializer
 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 .nested_serializers import *
 
@@ -48,14 +46,14 @@ class ProviderNetworkSerializer(PrimaryModelSerializer):
 # Circuits
 #
 
-class CircuitTypeSerializer(OrganizationalModelSerializer):
+class CircuitTypeSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
     circuit_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = CircuitType
         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',
         ]
 

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

@@ -34,7 +34,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
 #
 
 class CircuitTypeViewSet(CustomFieldModelViewSet):
-    queryset = CircuitType.objects.annotate(
+    queryset = CircuitType.objects.prefetch_related('tags').annotate(
         circuit_count=count_related(Circuit, 'type')
     )
     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(
         queryset=CircuitType.objects.all(),
         widget=forms.MultipleHiddenInput

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

@@ -75,11 +75,15 @@ class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
 
 class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = CircuitType
         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])
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class CircuitType(OrganizationalModel):
     """
     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(
         linkify=True
     )
+    tags = TagColumn(
+        url_name='circuits:circuittype_list'
+    )
     circuit_count = tables.Column(
         verbose_name='Circuits'
     )
@@ -89,7 +92,7 @@ class CircuitTypeTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 

+ 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'),
         ])
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'name': 'Circuit Type X',
             'slug': 'circuit-type-x',
             'description': 'A new circuit type',
+            'tags': [t.pk for t in tags],
         }
 
         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 netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import (
-    NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer,
-    WritableNestedSerializer,
+    NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
 )
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from users.api.nested_serializers import NestedUserSerializer
@@ -87,8 +86,8 @@ class RegionSerializer(NestedGroupModelSerializer):
     class Meta:
         model = Region
         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:
         model = SiteGroup
         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:
         model = Location
         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',
         ]
 
 
-class RackRoleSerializer(OrganizationalModelSerializer):
+class RackRoleSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
     rack_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = RackRole
         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
 #
 
-class ManufacturerSerializer(OrganizationalModelSerializer):
+class ManufacturerSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
     devicetype_count = serializers.IntegerField(read_only=True)
     inventoryitem_count = serializers.IntegerField(read_only=True)
@@ -263,7 +262,7 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
     class Meta:
         model = Manufacturer
         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',
         ]
 
@@ -411,7 +410,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
 # Devices
 #
 
-class DeviceRoleSerializer(OrganizationalModelSerializer):
+class DeviceRoleSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
     device_count = serializers.IntegerField(read_only=True)
     virtualmachine_count = serializers.IntegerField(read_only=True)
@@ -419,12 +418,12 @@ class DeviceRoleSerializer(OrganizationalModelSerializer):
     class Meta:
         model = DeviceRole
         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')
     manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
     device_count = serializers.IntegerField(read_only=True)
@@ -434,7 +433,7 @@ class PlatformSerializer(OrganizationalModelSerializer):
         model = Platform
         fields = [
             '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',
         'site_count',
         cumulative=True
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.RegionSerializer
     filterset_class = filtersets.RegionFilterSet
 
@@ -126,7 +126,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet):
         'group',
         'site_count',
         cumulative=True
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.SiteGroupSerializer
     filterset_class = filtersets.SiteGroupFilterSet
 
@@ -167,7 +167,7 @@ class LocationViewSet(CustomFieldModelViewSet):
         'location',
         'rack_count',
         cumulative=True
-    ).prefetch_related('site')
+    ).prefetch_related('site', 'tags')
     serializer_class = serializers.LocationSerializer
     filterset_class = filtersets.LocationFilterSet
 
@@ -177,7 +177,7 @@ class LocationViewSet(CustomFieldModelViewSet):
 #
 
 class RackRoleViewSet(CustomFieldModelViewSet):
-    queryset = RackRole.objects.annotate(
+    queryset = RackRole.objects.prefetch_related('tags').annotate(
         rack_count=count_related(Rack, 'role')
     )
     serializer_class = serializers.RackRoleSerializer
@@ -261,7 +261,7 @@ class RackReservationViewSet(ModelViewSet):
 #
 
 class ManufacturerViewSet(CustomFieldModelViewSet):
-    queryset = Manufacturer.objects.annotate(
+    queryset = Manufacturer.objects.prefetch_related('tags').annotate(
         devicetype_count=count_related(DeviceType, 'manufacturer'),
         inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
         platform_count=count_related(Platform, 'manufacturer')
@@ -340,7 +340,7 @@ class DeviceBayTemplateViewSet(ModelViewSet):
 #
 
 class DeviceRoleViewSet(CustomFieldModelViewSet):
-    queryset = DeviceRole.objects.annotate(
+    queryset = DeviceRole.objects.prefetch_related('tags').annotate(
         device_count=count_related(Device, 'device_role'),
         virtualmachine_count=count_related(VirtualMachine, 'role')
     )
@@ -353,7 +353,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet):
 #
 
 class PlatformViewSet(CustomFieldModelViewSet):
-    queryset = Platform.objects.annotate(
+    queryset = Platform.objects.prefetch_related('tags').annotate(
         device_count=count_related(Device, '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(
         queryset=Region.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -69,7 +69,7 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['parent', 'description']
 
 
-class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -132,7 +132,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
         ]
 
 
-class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Location.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -161,7 +161,7 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['parent', 'tenant', 'description']
 
 
-class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=RackRole.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -303,7 +303,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
         nullable_fields = []
 
 
-class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -345,7 +345,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
         nullable_fields = ['airflow']
 
 
-class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -367,7 +367,7 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['color', 'description']
 
 
-class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Platform.objects.all(),
         widget=forms.MultipleHiddenInput

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

@@ -70,11 +70,15 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm):
         required=False
     )
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = Region
         fields = (
-            'parent', 'name', 'slug', 'description',
+            'parent', 'name', 'slug', 'description', 'tags',
         )
 
 
@@ -84,11 +88,15 @@ class SiteGroupForm(BootstrapMixin, CustomFieldModelForm):
         required=False
     )
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = SiteGroup
         fields = (
-            'parent', 'name', 'slug', 'description',
+            'parent', 'name', 'slug', 'description', 'tags',
         )
 
 
@@ -187,15 +195,19 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         }
     )
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = Location
         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 = (
             ('Location', (
-                'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description',
+                'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
             )),
             ('Tenancy', ('tenant_group', 'tenant')),
         )
@@ -203,11 +215,15 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = RackRole
         fields = [
-            'name', 'slug', 'color', 'description',
+            'name', 'slug', 'color', 'description', 'tags',
         ]
 
 
@@ -343,11 +359,15 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = Manufacturer
         fields = [
-            'name', 'slug', 'description',
+            'name', 'slug', 'description', 'tags',
         ]
 
 
@@ -392,11 +412,15 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
 
 class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = DeviceRole
         fields = [
-            'name', 'slug', 'color', 'vm_role', 'description',
+            'name', 'slug', 'color', 'vm_role', 'description', 'tags',
         ]
 
 
@@ -408,11 +432,15 @@ class PlatformForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField(
         max_length=64
     )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = Platform
         fields = [
-            'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
+            'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
         ]
         widgets = {
             '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
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Manufacturer(OrganizationalModel):
     """
     A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@@ -351,7 +351,7 @@ class DeviceType(PrimaryModel):
 # Devices
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class DeviceRole(OrganizationalModel):
     """
     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])
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Platform(OrganizationalModel):
     """
     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
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class RackRole(OrganizationalModel):
     """
     Racks can be organized by functional role, similar to Devices.

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

@@ -25,7 +25,7 @@ __all__ = (
 # Regions
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Region(NestedGroupModel):
     """
     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
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class SiteGroup(NestedGroupModel):
     """
     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
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Location(NestedGroupModel):
     """
     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()
     vm_role = BooleanColumn()
+    tags = TagColumn(
+        url_name='dcim:devicerole_list'
+    )
     actions = ButtonsColumn(DeviceRole)
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -111,13 +116,16 @@ class PlatformTable(BaseTable):
         url_params={'platform_id': 'pk'},
         verbose_name='VMs'
     )
+    tags = TagColumn(
+        url_name='dcim:platform_list'
+    )
     actions = ButtonsColumn(Platform)
 
     class Meta(BaseTable.Meta):
         model = Platform
         fields = (
             'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
-            'description', 'actions',
+            'description', 'tags', 'actions',
         )
         default_columns = (
             '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'
     )
     slug = tables.Column()
+    tags = TagColumn(
+        url_name='dcim:manufacturer_list'
+    )
     actions = ButtonsColumn(Manufacturer)
 
     class Meta(BaseTable.Meta):
         model = Manufacturer
         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)
     rack_count = tables.Column(verbose_name='Racks')
     color = ColorColumn()
+    tags = TagColumn(
+        url_name='dcim:rackrole_list'
+    )
     actions = ButtonsColumn(RackRole)
 
     class Meta(BaseTable.Meta):
         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')
 
 

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

@@ -29,11 +29,14 @@ class RegionTable(BaseTable):
         url_params={'region_id': 'pk'},
         verbose_name='Sites'
     )
+    tags = TagColumn(
+        url_name='dcim:region_list'
+    )
     actions = ButtonsColumn(Region)
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -51,11 +54,14 @@ class SiteGroupTable(BaseTable):
         url_params={'group_id': 'pk'},
         verbose_name='Sites'
     )
+    tags = TagColumn(
+        url_name='dcim:sitegroup_list'
+    )
     actions = ButtonsColumn(SiteGroup)
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -114,6 +120,9 @@ class LocationTable(BaseTable):
         url_params={'location_id': 'pk'},
         verbose_name='Devices'
     )
+    tags = TagColumn(
+        url_name='dcim:location_list'
+    )
     actions = ButtonsColumn(
         model=Location,
         prepend_template=LOCATION_ELEVATIONS
@@ -121,5 +130,7 @@ class LocationTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')

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

@@ -31,11 +31,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         for region in regions:
             region.save()
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'name': 'Region X',
             'slug': 'region-x',
             'parent': regions[2].pk,
             'description': 'A new region',
+            'tags': [t.pk for t in tags],
         }
 
         cls.csv_data = (
@@ -65,11 +68,14 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         for sitegroup in sitegroups:
             sitegroup.save()
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'name': 'Site Group X',
             'slug': 'site-group-x',
             'parent': sitegroups[2].pk,
             'description': 'A new site group',
+            'tags': [t.pk for t in tags],
         }
 
         cls.csv_data = (
@@ -169,12 +175,15 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         for location in locations:
             location.save()
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'name': 'Location X',
             'slug': 'location-x',
             'site': site.pk,
             'tenant': tenant.pk,
             'description': 'A new location',
+            'tags': [t.pk for t in tags],
         }
 
         cls.csv_data = (
@@ -201,11 +210,14 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             RackRole(name='Rack Role 3', slug='rack-role-3'),
         ])
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'name': 'Rack Role X',
             'slug': 'rack-role-x',
             'color': 'c0c0c0',
             'description': 'New role',
+            'tags': [t.pk for t in tags],
         }
 
         cls.csv_data = (
@@ -368,10 +380,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
         ])
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'name': 'Manufacturer X',
             'slug': 'manufacturer-x',
             'description': 'A new manufacturer',
+            'tags': [t.pk for t in tags],
         }
 
         cls.csv_data = (
@@ -1034,12 +1049,15 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             DeviceRole(name='Device Role 3', slug='device-role-3'),
         ])
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'name': 'Devie Role X',
             'slug': 'device-role-x',
             'color': 'c0c0c0',
             'vm_role': False,
             'description': 'New device role',
+            'tags': [t.pk for t in tags],
         }
 
         cls.csv_data = (
@@ -1069,6 +1087,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer),
         ])
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'name': 'Platform X',
             'slug': 'platform-x',
@@ -1076,6 +1096,7 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             'napalm_driver': 'junos',
             'napalm_args': None,
             'description': 'A new platform',
+            'tags': [t.pk for t in tags],
         }
 
         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.models import *
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
-from netbox.api.serializers import OrganizationalModelSerializer
 from netbox.api.serializers import PrimaryModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import get_serializer_for_model
@@ -66,14 +65,14 @@ class RouteTargetSerializer(PrimaryModelSerializer):
 # RIRs/aggregates
 #
 
-class RIRSerializer(OrganizationalModelSerializer):
+class RIRSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
     aggregate_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = RIR
         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',
         ]
 
@@ -97,7 +96,7 @@ class AggregateSerializer(PrimaryModelSerializer):
 # VLANs
 #
 
-class RoleSerializer(OrganizationalModelSerializer):
+class RoleSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
     prefix_count = serializers.IntegerField(read_only=True)
     vlan_count = serializers.IntegerField(read_only=True)
@@ -105,12 +104,12 @@ class RoleSerializer(OrganizationalModelSerializer):
     class Meta:
         model = Role
         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')
     scope_type = ContentTypeField(
         queryset=ContentType.objects.filter(
@@ -126,8 +125,8 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
     class Meta:
         model = VLANGroup
         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 = []
 

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

@@ -48,7 +48,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
 class RIRViewSet(CustomFieldModelViewSet):
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.RIRSerializer
     filterset_class = filtersets.RIRFilterSet
 
@@ -71,7 +71,7 @@ class RoleViewSet(CustomFieldModelViewSet):
     queryset = Role.objects.annotate(
         prefix_count=count_related(Prefix, 'role'),
         vlan_count=count_related(VLAN, 'role')
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.RoleSerializer
     filterset_class = filtersets.RoleFilterSet
 
@@ -126,7 +126,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
 class VLANGroupViewSet(CustomFieldModelViewSet):
     queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.VLANGroupSerializer
     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(
         queryset=RIR.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -120,7 +120,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
         }
 
 
-class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Role.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -280,7 +280,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
         ]
 
 
-class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=VLANGroup.objects.all(),
         widget=forms.MultipleHiddenInput

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

@@ -82,11 +82,15 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 class RIRForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = RIR
         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):
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = Role
         fields = [
-            'name', 'slug', 'weight', 'description',
+            'name', 'slug', 'weight', 'description', 'tags',
         ]
 
 
@@ -530,15 +538,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
         }
     )
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = VLANGroup
         fields = [
             'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
-            'clustergroup', 'cluster',
+            'clustergroup', 'cluster', 'tags',
         ]
         fieldsets = (
-            ('VLAN Group', ('name', 'slug', 'description')),
+            ('VLAN Group', ('name', 'slug', 'description', 'tags')),
             ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
         )
         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):
     """
     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)
 
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Role(OrganizationalModel):
     """
     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):
     """
     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'},
         verbose_name='Aggregates'
     )
+    tags = TagColumn(
+        url_name='ipam:rir_list'
+    )
     actions = ButtonsColumn(RIR)
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -144,11 +147,14 @@ class RoleTable(BaseTable):
         url_params={'role_id': 'pk'},
         verbose_name='VLANs'
     )
+    tags = TagColumn(
+        url_name='ipam:role_list'
+    )
     actions = ButtonsColumn(Role)
 
     class Meta(BaseTable.Meta):
         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')
 
 

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

@@ -74,6 +74,9 @@ class VLANGroupTable(BaseTable):
         url_params={'group_id': 'pk'},
         verbose_name='VLANs'
     )
+    tags = TagColumn(
+        url_name='ipam:vlangroup_list'
+    )
     actions = ButtonsColumn(
         model=VLANGroup,
         prepend_template=VLANGROUP_ADD_VLAN
@@ -81,7 +84,7 @@ class VLANGroupTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 

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

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

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

@@ -147,13 +147,6 @@ class NestedTagSerializer(WritableNestedSerializer):
 # Base model serializers
 #
 
-class OrganizationalModelSerializer(CustomFieldModelSerializer):
-    """
-    Adds support for custom fields.
-    """
-    pass
-
-
 class PrimaryModelSerializer(CustomFieldModelSerializer):
     """
     Adds support for custom fields and tags.
@@ -189,9 +182,9 @@ class PrimaryModelSerializer(CustomFieldModelSerializer):
         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)
 

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

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

+ 15 - 6
netbox/netbox/models.py

@@ -143,6 +143,18 @@ class CustomValidationMixin(models.Model):
         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
 
@@ -166,7 +178,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel):
         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.
     """
@@ -175,15 +187,12 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin,
         object_id_field='assigned_object_id',
         content_type_field='assigned_object_type'
     )
-    tags = TaggableManager(
-        through='extras.TaggedItem'
-    )
 
     class Meta:
         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
     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
     any real information about the infrastructure being modeled (for example, functional device roles). Organizational

+ 1 - 1
netbox/templates/circuits/circuit.html

@@ -65,7 +65,7 @@
             </div>
         </div>
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% include 'inc/panels/comments.html' %}
         {% plugin_left_page object %}
 	</div>

+ 1 - 0
netbox/templates/circuits/circuittype.html

@@ -28,6 +28,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
   </div>
 	<div class="col col-md-6">

+ 1 - 1
netbox/templates/circuits/provider.html

@@ -47,7 +47,7 @@
                 </table>
             </div>
         </div>
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:provider_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">

+ 1 - 1
netbox/templates/circuits/providernetwork.html

@@ -38,7 +38,7 @@
     </div>
     <div class="col col-md-6">
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:providernetwork_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% include 'inc/panels/comments.html' %}
         {% plugin_right_page object %}
     </div>

+ 1 - 1
netbox/templates/dcim/cable.html

@@ -64,7 +64,7 @@
                 </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:cable_list' %}
+            {% include 'inc/panels/tags.html' %}
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/consoleport.html

@@ -41,7 +41,7 @@
                 </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all %}
+            {% include 'inc/panels/tags.html' %}
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/consoleserverport.html

@@ -41,7 +41,7 @@
                 </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all %}
+            {% include 'inc/panels/tags.html' %}
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/device.html

@@ -221,7 +221,7 @@
                 </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:device_list' %}
+            {% include 'inc/panels/tags.html' %}
             {% include 'inc/panels/comments.html' %}
             {% plugin_left_page object %}
         </div>

+ 1 - 1
netbox/templates/dcim/devicebay.html

@@ -33,7 +33,7 @@
                 </div>
             </div>
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all %}
+        {% include 'inc/panels/tags.html' %}
         {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">

+ 1 - 0
netbox/templates/dcim/devicerole.html

@@ -58,6 +58,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/devicetype.html

@@ -88,7 +88,7 @@
         </div>
         <div class="col col-md-6">
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:devicetype_list' %}
+            {% include 'inc/panels/tags.html' %}
             {% include 'inc/panels/comments.html' %}
             {% plugin_right_page object %}
         </div>

+ 1 - 1
netbox/templates/dcim/frontport.html

@@ -53,7 +53,7 @@
                 </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all %}
+            {% include 'inc/panels/tags.html' %}
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/interface.html

@@ -103,7 +103,7 @@
                 </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all %}
+            {% include 'inc/panels/tags.html' %}
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/inventoryitem.html

@@ -65,7 +65,7 @@
                 </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all %}
+            {% include 'inc/panels/tags.html' %}
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">

+ 1 - 0
netbox/templates/dcim/location.html

@@ -68,6 +68,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
   </div>
 	<div class="col col-md-6">

+ 1 - 0
netbox/templates/dcim/manufacturer.html

@@ -34,6 +34,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">

+ 1 - 0
netbox/templates/dcim/platform.html

@@ -55,6 +55,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/powerfeed.html

@@ -108,7 +108,7 @@
             </div>
         </div>
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:powerfeed_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/poweroutlet.html

@@ -45,7 +45,7 @@
                 </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all %}
+            {% include 'inc/panels/tags.html' %}
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/powerpanel.html

@@ -39,7 +39,7 @@
                 </table>
             </div>
         </div>
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:powerpanel_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% plugin_left_page object %}
     </div>
 	<div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/powerport.html

@@ -45,7 +45,7 @@
                 </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all %}
+            {% include 'inc/panels/tags.html' %}
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/rack.html

@@ -163,7 +163,7 @@
             </div>
         </div>
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:rack_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% include 'inc/panels/comments.html' %}
         {% if power_feeds %}
             <div class="card">

+ 1 - 1
netbox/templates/dcim/rackreservation.html

@@ -84,7 +84,7 @@
             </div>
         </div>
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:rackreservation_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% plugin_left_page object %}
 	</div>
     <div class="col col-12 col-xl-7">

+ 1 - 0
netbox/templates/dcim/rackrole.html

@@ -34,6 +34,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">

+ 1 - 1
netbox/templates/dcim/rearport.html

@@ -47,7 +47,7 @@
                 </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all %}
+            {% include 'inc/panels/tags.html' %}
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">

+ 1 - 0
netbox/templates/dcim/region.html

@@ -45,6 +45,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/contacts.html' %}
     {% plugin_left_page object %}

+ 1 - 2
netbox/templates/dcim/site.html

@@ -169,7 +169,6 @@
                         <div class="float-end text-warning">
                           <i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
                         </div>
-                        <a href="tel:{{ object.contact_
                         <a href="mailto:{{ object.contact_email }}">{{ object.contact_email }}</a>
                       {% else %}
                         <span class="text-muted">&mdash;</span>
@@ -181,7 +180,7 @@
             </div>
         </div>
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:site_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% include 'inc/panels/comments.html' %}
         {% plugin_left_page object %}
     </div>

+ 1 - 0
netbox/templates/dcim/sitegroup.html

@@ -45,6 +45,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/contacts.html' %}
     {% plugin_left_page object %}

+ 1 - 1
netbox/templates/dcim/virtualchassis.html

@@ -39,7 +39,7 @@
             </div>
         </div>
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:virtualchassis_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% plugin_left_page object %}
     </div>
     <div class="col col-md-8">

+ 9 - 6
netbox/templates/inc/panels/tags.html

@@ -1,11 +1,14 @@
 {% load helpers %}
+
 <div class="card">
-  <h5 class="card-header">
-    Tags
-  </h5>
+  <h5 class="card-header">Tags</h5>
   <div class="card-body">
-    {% for tag in tags.all %} {% tag tag url %} {% empty %}
-    <span class="text-muted">No tags assigned</span>
-    {% endfor %}
+    {% with url=object|validated_viewname:"list" %}
+      {% for tag in object.tags.all %}
+        {% tag tag url %}
+      {% empty %}
+        <span class="text-muted">No tags assigned</span>
+      {% endfor %}
+    {% endwith %}
   </div>
 </div>

+ 1 - 1
netbox/templates/ipam/aggregate.html

@@ -65,7 +65,7 @@
     </div>
     <div class="col col-md-6">
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:aggregate_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% plugin_right_page object %}
     </div>
 </div>

+ 1 - 1
netbox/templates/ipam/ipaddress.html

@@ -145,7 +145,7 @@
 
 <div class="row my-3">
     <div class="col col-md-4">
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:ipaddress_list' %}
+        {% include 'inc/panels/tags.html' %}
     </div>
     
 </div>

+ 1 - 1
netbox/templates/ipam/iprange.html

@@ -82,7 +82,7 @@
         {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:prefix_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% include 'inc/panels/custom_fields.html' %}
         {% plugin_right_page object %}
     </div>

+ 1 - 1
netbox/templates/ipam/prefix.html

@@ -122,7 +122,7 @@
     </div>
     <div class="col col-md-6">
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:prefix_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% plugin_right_page object %}
     </div>
 </div>

+ 1 - 0
netbox/templates/ipam/rir.html

@@ -38,6 +38,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">

+ 1 - 0
netbox/templates/ipam/role.html

@@ -32,6 +32,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">

+ 1 - 1
netbox/templates/ipam/routetarget.html

@@ -30,7 +30,7 @@
           </table>
         </div>
       </div>
-      {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:routetarget_list' %}
+      {% include 'inc/panels/tags.html' %}
       {% include 'inc/panels/custom_fields.html' %}
       {% plugin_left_page object %}
     </div>

+ 1 - 1
netbox/templates/ipam/service.html

@@ -61,7 +61,7 @@
     </div>
     <div class="col col-md-6">
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:service_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% plugin_right_page object %}
     </div>
 </div>

+ 1 - 1
netbox/templates/ipam/vlan.html

@@ -83,7 +83,7 @@
         </div>
         <div class="col col-md-6">
             {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:vlan_list' %}
+            {% include 'inc/panels/tags.html' %}
             {% plugin_right_page object %}
         </div>
     </div>

+ 1 - 0
netbox/templates/ipam/vlangroup.html

@@ -54,6 +54,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">

+ 1 - 1
netbox/templates/ipam/vrf.html

@@ -60,7 +60,7 @@
       {% plugin_left_page object %}
   </div>
   <div class="col col-md-6">
-    {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:vrf_list' %}
+    {% include 'inc/panels/tags.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% plugin_right_page object %}
 	</div>

+ 1 - 1
netbox/templates/tenancy/contact.html

@@ -60,7 +60,7 @@
     </div>
     <div class="col col-md-5">
       {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/tags.html' with tags=object.tags.all url='tenancy:tenant_list' %}
+      {% include 'inc/panels/tags.html' %}
       {% plugin_right_page object %}
     </div>
   </div>

+ 1 - 0
netbox/templates/tenancy/contactgroup.html

@@ -45,6 +45,7 @@
           </table>
         </div>
       </div>
+      {% include 'inc/panels/tags.html' %}
       {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">

+ 1 - 0
netbox/templates/tenancy/contactrole.html

@@ -30,6 +30,7 @@
           </table>
         </div>
       </div>
+      {% include 'inc/panels/tags.html' %}
       {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">

+ 1 - 1
netbox/templates/tenancy/tenant.html

@@ -36,7 +36,7 @@
             </div>
         </div>
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='tenancy:tenant_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% include 'inc/panels/comments.html' %}
         {% include 'inc/panels/contacts.html' %}
         {% plugin_left_page object %}

+ 1 - 0
netbox/templates/tenancy/tenantgroup.html

@@ -45,6 +45,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
   </div>
 	<div class="col col-md-6">

+ 1 - 1
netbox/templates/virtualization/cluster.html

@@ -61,7 +61,7 @@
   </div>
   <div class="col col-md-6">
     {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/tags.html' with tags=object.tags.all url='virtualization:cluster_list' %}
+    {% include 'inc/panels/tags.html' %}
     {% include 'inc/panels/contacts.html' %}
     {% plugin_right_page object %}
   </div>

+ 1 - 0
netbox/templates/virtualization/clustergroup.html

@@ -28,6 +28,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">

+ 1 - 0
netbox/templates/virtualization/clustertype.html

@@ -28,6 +28,7 @@
         </table>
       </div>
     </div>
+    {% include 'inc/panels/tags.html' %}
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">

+ 1 - 1
netbox/templates/virtualization/virtualmachine.html

@@ -90,7 +90,7 @@
             </div>
         </div>
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='virtualization:virtualmachine_list' %}
+        {% include 'inc/panels/tags.html' %}
         {% include 'inc/panels/comments.html' %}
         {% plugin_left_page object %}
     </div>

+ 2 - 2
netbox/templates/virtualization/vminterface.html

@@ -70,8 +70,8 @@
     </div>
     <div class="col col-md-6">
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all %}
-          {% plugin_right_page object %}
+        {% include 'inc/panels/tags.html' %}
+        {% plugin_right_page object %}
     </div>
 </div>
 <div class="row mb-3">

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

@@ -2,7 +2,7 @@ from django.contrib.auth.models import ContentType
 from rest_framework import serializers
 
 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.models import *
 from .nested_serializers import *
@@ -20,8 +20,8 @@ class TenantGroupSerializer(NestedGroupModelSerializer):
     class Meta:
         model = TenantGroup
         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:
         model = ContactGroup
         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')
 
     class Meta:
         model = ContactRole
         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',
         'tenant_count',
         cumulative=True
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.TenantGroupSerializer
     filterset_class = filtersets.TenantGroupFilterSet
 
@@ -64,28 +64,24 @@ class ContactGroupViewSet(CustomFieldModelViewSet):
         'group',
         'contact_count',
         cumulative=True
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.ContactGroupSerializer
     filterset_class = filtersets.ContactGroupFilterSet
 
 
 class ContactRoleViewSet(CustomFieldModelViewSet):
-    queryset = ContactRole.objects.all()
+    queryset = ContactRole.objects.prefetch_related('tags')
     serializer_class = serializers.ContactRoleSerializer
     filterset_class = filtersets.ContactRoleFilterSet
 
 
 class ContactViewSet(CustomFieldModelViewSet):
-    queryset = Contact.objects.prefetch_related(
-        'group', 'tags'
-    )
+    queryset = Contact.objects.prefetch_related('group', 'tags')
     serializer_class = serializers.ContactSerializer
     filterset_class = filtersets.ContactFilterSet
 
 
 class ContactAssignmentViewSet(CustomFieldModelViewSet):
-    queryset = ContactAssignment.objects.prefetch_related(
-        'contact', 'role'
-    )
+    queryset = ContactAssignment.objects.prefetch_related('contact', 'role')
     serializer_class = serializers.ContactAssignmentSerializer
     filterset_class = filtersets.ContactAssignmentFilterSet

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

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

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

@@ -28,11 +28,15 @@ class TenantGroupForm(BootstrapMixin, CustomFieldModelForm):
         required=False
     )
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = TenantGroup
         fields = [
-            'parent', 'name', 'slug', 'description',
+            'parent', 'name', 'slug', 'description', 'tags',
         ]
 
 
@@ -68,18 +72,26 @@ class ContactGroupForm(BootstrapMixin, CustomFieldModelForm):
         required=False
     )
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = ContactGroup
-        fields = ['parent', 'name', 'slug', 'description']
+        fields = ('parent', 'name', 'slug', 'description', 'tags')
 
 
 class ContactRoleForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
 
     class Meta:
         model = ContactRole
-        fields = ['name', 'slug', 'description']
+        fields = ('name', 'slug', 'description', 'tags')
 
 
 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
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class TenantGroup(NestedGroupModel):
     """
     An arbitrary collection of Tenants.
@@ -111,7 +111,7 @@ class Tenant(PrimaryModel):
 # Contacts
 #
 
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class ContactGroup(NestedGroupModel):
     """
     An arbitrary collection of Contacts.
@@ -145,7 +145,7 @@ class ContactGroup(NestedGroupModel):
         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):
     """
     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'},
         verbose_name='Tenants'
     )
+    tags = TagColumn(
+        url_name='tenancy:tenantgroup_list'
+    )
     actions = ButtonsColumn(TenantGroup)
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -96,11 +99,14 @@ class ContactGroupTable(BaseTable):
         url_params={'role_id': 'pk'},
         verbose_name='Contacts'
     )
+    tags = TagColumn(
+        url_name='tenancy:contactgroup_list'
+    )
     actions = ButtonsColumn(ContactGroup)
 
     class Meta(BaseTable.Meta):
         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')
 
 

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

@@ -16,10 +16,13 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         for tenanantgroup in tenant_groups:
             tenanantgroup.save()
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'name': 'Tenant Group X',
             'slug': 'tenant-group-x',
             'description': 'A new tenant group',
+            'tags': [t.pk for t in tags],
         }
 
         cls.csv_data = (
@@ -90,10 +93,13 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         for tenanantgroup in contact_groups:
             tenanantgroup.save()
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'name': 'Contact Group X',
             'slug': 'contact-group-x',
             'description': 'A new contact group',
+            'tags': [t.pk for t in tags],
         }
 
         cls.csv_data = (
@@ -120,10 +126,13 @@ class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             ContactRole(name='Contact Role 3', slug='contact-role-3'),
         ])
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         cls.form_data = {
             'name': 'Devie Role X',
             'slug': 'contact-role-x',
             'description': 'New contact role',
+            'tags': [t.pk for t in tags],
         }
 
         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.models import VLAN
 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 virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -17,26 +17,26 @@ from .nested_serializers import *
 # Clusters
 #
 
-class ClusterTypeSerializer(OrganizationalModelSerializer):
+class ClusterTypeSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
     cluster_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = ClusterType
         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',
         ]
 
 
-class ClusterGroupSerializer(OrganizationalModelSerializer):
+class ClusterGroupSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
     cluster_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = ClusterGroup
         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',
         ]
 

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

@@ -23,7 +23,7 @@ class VirtualizationRootView(APIRootView):
 class ClusterTypeViewSet(CustomFieldModelViewSet):
     queryset = ClusterType.objects.annotate(
         cluster_count=count_related(Cluster, 'type')
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.ClusterTypeSerializer
     filterset_class = filtersets.ClusterTypeFilterSet
 
@@ -31,7 +31,7 @@ class ClusterTypeViewSet(CustomFieldModelViewSet):
 class ClusterGroupViewSet(CustomFieldModelViewSet):
     queryset = ClusterGroup.objects.annotate(
         cluster_count=count_related(Cluster, 'group')
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.ClusterGroupSerializer
     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(
         queryset=ClusterType.objects.all(),
         widget=forms.MultipleHiddenInput
@@ -37,7 +37,7 @@ class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['description']
 
 
-class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+class ClusterGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         widget=forms.MultipleHiddenInput

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

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

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio