diff --git a/docs/development/models.md b/docs/development/models.md index 93a10fff6..59e795cf7 100644 --- a/docs/development/models.md +++ b/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: | | | | | diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index 29cc8b757..fe6a1ef36 100644 --- a/docs/models/extras/tag.md +++ b/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. diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ac6285610..0033e1425 100644 --- a/netbox/circuits/api/serializers.py +++ b/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', ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 3bceb2de0..2b3e3b122 100644 --- a/netbox/circuits/api/views.py +++ b/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 diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 638426a5e..7bf5644b9 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/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 diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 659939293..5679dbc94 100644 --- a/netbox/circuits/forms/models.py +++ b/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', ] diff --git a/netbox/circuits/migrations/0003_extend_tag_support.py b/netbox/circuits/migrations/0003_extend_tag_support.py new file mode 100644 index 000000000..e5e6ee262 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 3d213b48d..e6e03052d 100644 --- a/netbox/circuits/models.py +++ b/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 diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 2e31237b6..d0b0797e2 100644 --- a/netbox/circuits/tables.py +++ b/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') diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index ccb4a869a..851d52ae8 100644 --- a/netbox/circuits/tests/test_views.py +++ b/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 = ( diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9b0e7f5b3..ef4f49247 100644 --- a/netbox/dcim/api/serializers.py +++ b/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', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2b9d9734c..799a5e703 100644 --- a/netbox/dcim/api/views.py +++ b/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') ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 06ccc958c..d08692c26 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/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 diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 8236b1a97..a3dac09dd 100644 --- a/netbox/dcim/forms/models.py +++ b/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(), diff --git a/netbox/dcim/migrations/0138_extend_tag_support.py b/netbox/dcim/migrations/0138_extend_tag_support.py new file mode 100644 index 000000000..763b53c50 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 308a094c3..2b3b80d24 100644 --- a/netbox/dcim/models/devices.py +++ b/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". diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 47fcd42e4..a6be069b6 100644 --- a/netbox/dcim/models/racks.py +++ b/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. diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index ab9d8e82d..a978e69e6 100644 --- a/netbox/dcim/models/sites.py +++ b/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 diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index a2d3f3da2..f47073848 100644 --- a/netbox/dcim/tables/devices.py +++ b/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', diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index b3310d5d2..9631b5709 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/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', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index fcc3ed4d2..bdc5ae713 100644 --- a/netbox/dcim/tables/racks.py +++ b/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') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 3ff6ab75b..65419e9c8 100644 --- a/netbox/dcim/tables/sites.py +++ b/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') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index a9c191679..4565c898b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/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 = ( diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 183c45b2a..2b221fdab 100644 --- a/netbox/ipam/api/serializers.py +++ b/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 = [] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 69b6d97f0..a043bd88c 100644 --- a/netbox/ipam/api/views.py +++ b/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 diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 895dbe200..43bf40f88 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/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 diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d28f7b3ae..a9c8a0910 100644 --- a/netbox/ipam/forms/models.py +++ b/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 = { diff --git a/netbox/ipam/migrations/0051_extend_tag_support.py b/netbox/ipam/migrations/0051_extend_tag_support.py new file mode 100644 index 000000000..ea31a6645 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 4fc2b5dbb..514e87a62 100644 --- a/netbox/ipam/models/ip.py +++ b/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 diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 4ba8d7041..14eaa7ccc 100644 --- a/netbox/ipam/models/vlans.py +++ b/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. diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index ddad6c573..a2a0c67b1 100644 --- a/netbox/ipam/tables/ip.py +++ b/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') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index fd1e92be8..4c0d5d729 100644 --- a/netbox/ipam/tables/vlans.py +++ b/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') diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 2a0bfdf32..5440efcb6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/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 = ( diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py index d17751e25..9f51d475d 100644 --- a/netbox/netbox/api/serializers.py +++ b/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) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 181b9a0c6..7d71bd1fb 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -41,6 +41,7 @@ class ObjectType( class OrganizationalObjectType( ChangelogMixin, CustomFieldsMixin, + TagsMixin, BaseObjectType ): """ diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 317548921..95cea6a93 100644 --- a/netbox/netbox/models.py +++ b/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 diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 27a14b350..90c13725c 100644 --- a/netbox/tenancy/api/serializers.py +++ b/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', ] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 7ce16c143..8c7c33aba 100644 --- a/netbox/tenancy/api/views.py +++ b/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 diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index a34b8def1..f461fe73c 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/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 diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index b15065705..0237e4ef8 100644 --- a/netbox/tenancy/forms/models.py +++ b/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): diff --git a/netbox/tenancy/migrations/0004_extend_tag_support.py b/netbox/tenancy/migrations/0004_extend_tag_support.py new file mode 100644 index 000000000..942be38b5 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index c709236e2..01ea2d0d5 100644 --- a/netbox/tenancy/models.py +++ b/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. diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 5b254842b..02c431846 100644 --- a/netbox/tenancy/tables.py +++ b/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') diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index fb7ff3ce3..dcfcc1652 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/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 = ( diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 1928960a9..ef8c975d3 100644 --- a/netbox/virtualization/api/serializers.py +++ b/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', ] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 8eebd2120..d07ace3d5 100644 --- a/netbox/virtualization/api/views.py +++ b/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 diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index c140fbc73..d18d432cd 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/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 diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index d66bc9f1f..88ebc9e83 100644 --- a/netbox/virtualization/forms/models.py +++ b/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): diff --git a/netbox/virtualization/migrations/0025_extend_tag_support.py b/netbox/virtualization/migrations/0025_extend_tag_support.py new file mode 100644 index 000000000..c77aee194 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 11792944a..bd64f56cf 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -30,7 +30,7 @@ __all__ = ( # Cluster types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterType(OrganizationalModel): """ A type of Cluster. @@ -64,7 +64,7 @@ class ClusterType(OrganizationalModel): # Cluster groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index b0e922e71..64b376e1d 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -40,11 +40,14 @@ class ClusterTypeTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) + tags = TagColumn( + url_name='virtualization:clustertype_list' + ) actions = ButtonsColumn(ClusterType) class Meta(BaseTable.Meta): 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') @@ -60,11 +63,14 @@ class ClusterGroupTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) + tags = TagColumn( + url_name='virtualization:clustergroup_list' + ) actions = ButtonsColumn(ClusterGroup) class Meta(BaseTable.Meta): 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') diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 020c9ebc5..138b1afae 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -22,10 +22,13 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Cluster Group X', 'slug': 'cluster-group-x', 'description': 'A new cluster group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -52,10 +55,13 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ClusterType(name='Cluster Type 3', slug='cluster-type-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Cluster Type X', 'slug': 'cluster-type-x', 'description': 'A new cluster type', + 'tags': [t.pk for t in tags], } cls.csv_data = (