diff --git a/base_requirements.txt b/base_requirements.txt index caf7ba5f3..a57e88604 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -42,10 +42,6 @@ django-tables2 # https://github.com/alex/django-taggit django-taggit -# A Django REST Framework serializer which represents tags -# https://github.com/glemmaPaul/django-taggit-serializer -django-taggit-serializer - # A Django field for representing time zones # https://github.com/mfogel/django-timezone-field/ django-timezone-field diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 60f554f4d..552859d4d 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -10,6 +10,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo ### Enhancements +* [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object * [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components * [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations @@ -18,6 +19,20 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.) * `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`. +### REST API Changes + +* The count of `tagged_items` is no longer included when viewing the tags list when `brief` is passed. +* The assignment of tags to an object is now achieved in the same manner as specifying any other related device. The `tags` field accepts a list of JSON objects each matching a desired tag. (Alternatively, a list of numeric primary keys corresponding to tags may be passed instead.) For example: + +```json +"tags": [ + {"name": "First Tag"}, + {"name": "Second Tag"} +] +``` + +* The `tags` field of an object now includes a more complete representation of each tag, rather than just its name. + ### Other Changes * The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey. diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 6bac48a59..e8171e2fb 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,11 +1,11 @@ from rest_framework import serializers -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from circuits.choices import CircuitStatusChoices from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer from dcim.api.serializers import ConnectedEndpointSerializer from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer from .nested_serializers import * @@ -15,8 +15,7 @@ from .nested_serializers import * # Providers # -class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer): - tags = TagListSerializerField(required=False) +class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): circuit_count = serializers.IntegerField(read_only=True) class Meta: @@ -49,14 +48,13 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer): fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id'] -class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): +class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): provider = NestedProviderSerializer() status = ChoiceField(choices=CircuitStatusChoices, required=False) type = NestedCircuitTypeSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) termination_a = CircuitCircuitTerminationSerializer(read_only=True) termination_z = CircuitCircuitTerminationSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = Circuit diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 2185d1eab..341a7a9b7 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -3,8 +3,8 @@ from django import forms from dcim.models import Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, - TagField, ) +from extras.models import Tag from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -23,7 +23,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider class ProviderForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -165,7 +166,8 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=CircuitType.objects.all() ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 9cc7af6ae..38365521a 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -26,7 +26,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'noc_contact': 'noc@example.com', 'admin_contact': 'admin@example.com', 'comments': 'Another provider', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -106,7 +106,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'commit_rate': 1000, 'description': 'A new circuit', 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -124,5 +124,4 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'commit_rate': 2000, 'description': 'New description', 'comments': 'New comments', - } diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 5250045c5..c684b8041 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -2,7 +2,6 @@ from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.choices import * from dcim.constants import * @@ -14,6 +13,7 @@ from dcim.models import ( VirtualChassis, ) from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN from tenancy.api.nested_serializers import NestedTenantSerializer @@ -67,12 +67,11 @@ class RegionSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'parent', 'description', 'site_count'] -class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): +class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=SiteStatusChoices, required=False) region = NestedRegionSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(required=False) - tags = TagListSerializerField(required=False) circuit_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) @@ -112,7 +111,7 @@ class RackRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'color', 'description', 'rack_count'] -class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): +class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): site = NestedSiteSerializer() group = NestedRackGroupSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True) @@ -121,7 +120,6 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False) width = ChoiceField(choices=RackWidthChoices, required=False) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) - tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True) @@ -161,11 +159,10 @@ class RackUnitSerializer(serializers.Serializer): device = NestedDeviceSerializer(read_only=True) -class RackReservationSerializer(ValidatedModelSerializer): +class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer): rack = NestedRackSerializer() user = NestedUserSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) class Meta: model = RackReservation @@ -224,10 +221,9 @@ class ManufacturerSerializer(ValidatedModelSerializer): ] -class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): +class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) - tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) class Meta: @@ -363,7 +359,7 @@ class PlatformSerializer(ValidatedModelSerializer): ] -class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): +class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) @@ -378,7 +374,6 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): parent_device = serializers.SerializerMethodField() cluster = NestedClusterSerializer(required=False, allow_null=True) virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) class Meta: model = Device @@ -434,7 +429,7 @@ class DeviceNAPALMSerializer(serializers.Serializer): method = serializers.DictField() -class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): +class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, @@ -442,7 +437,6 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer) required=False ) cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = ConsoleServerPort @@ -452,7 +446,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer) ] -class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, @@ -460,7 +454,6 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): required=False ) cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = ConsolePort @@ -470,7 +463,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): ] -class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=PowerOutletTypeChoices, @@ -488,9 +481,6 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): cable = NestedCableSerializer( read_only=True ) - tags = TagListSerializerField( - required=False - ) class Meta: model = PowerOutlet @@ -500,7 +490,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): ] -class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=PowerPortTypeChoices, @@ -508,7 +498,6 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): required=False ) cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = PowerPort @@ -518,7 +507,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): ] -class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) lag = NestedInterfaceSerializer(required=False, allow_null=True) @@ -531,7 +520,6 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): many=True ) cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) count_ipaddresses = serializers.IntegerField(read_only=True) class Meta: @@ -563,11 +551,10 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): return super().validate(data) -class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer): +class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = RearPort @@ -585,22 +572,20 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name'] -class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer): +class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) rear_port = FrontPortRearPortSerializer() cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = FrontPort fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags'] -class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): +class DeviceBaySerializer(TaggedObjectSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) class Meta: model = DeviceBay @@ -611,12 +596,11 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): # Inventory items # -class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer): +class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() # Provide a default value to satisfy UniqueTogetherValidator parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) - tags = TagListSerializerField(required=False) class Meta: model = InventoryItem @@ -630,7 +614,7 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer): # Cables # -class CableSerializer(ValidatedModelSerializer): +class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer): termination_a_type = ContentTypeField( queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) ) @@ -641,7 +625,6 @@ class CableSerializer(ValidatedModelSerializer): termination_b = serializers.SerializerMethodField(read_only=True) status = ChoiceField(choices=CableStatusChoices, required=False) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) - tags = TagListSerializerField(required=False) class Meta: model = Cable @@ -710,9 +693,8 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): # Virtual chassis # -class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer): +class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer): master = NestedDeviceSerializer() - tags = TagListSerializerField(required=False) member_count = serializers.IntegerField(read_only=True) class Meta: @@ -724,14 +706,13 @@ class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer): # Power panels # -class PowerPanelSerializer(ValidatedModelSerializer): +class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer): site = NestedSiteSerializer() rack_group = NestedRackGroupSerializer( required=False, allow_null=True, default=None ) - tags = TagListSerializerField(required=False) powerfeed_count = serializers.IntegerField(read_only=True) class Meta: @@ -739,7 +720,7 @@ class PowerPanelSerializer(ValidatedModelSerializer): fields = ['id', 'site', 'rack_group', 'name', 'tags', 'powerfeed_count'] -class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer): +class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( required=False, @@ -762,9 +743,6 @@ class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer): choices=PowerFeedPhaseChoices, default=PowerFeedPhaseChoices.PHASE_SINGLE ) - tags = TagListSerializerField( - required=False - ) class Meta: model = PowerFeed diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 00e2d46cb..4f5113a5e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -14,8 +14,9 @@ from timezone_field import TimeZoneFormField from circuits.models import Circuit, Provider from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, - LocalConfigContextFilterForm, TagField, + LocalConfigContextFilterForm, ) +from extras.models import Tag from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm @@ -225,7 +226,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) slug = SlugField() comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -486,7 +488,8 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -766,7 +769,8 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): ), widget=StaticSelect2() ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -911,7 +915,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): slug_source='model' ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -1736,11 +1741,14 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField(required=False) local_context_data = JSONField( required=False, label='' ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Device @@ -2229,7 +2237,8 @@ class ConsolePortFilterForm(DeviceComponentFilterForm): class ConsolePortForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2256,7 +2265,8 @@ class ConsolePortCreateForm(LabeledComponentForm): max_length=100, required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2312,7 +2322,8 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm): class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2339,7 +2350,8 @@ class ConsoleServerPortCreateForm(LabeledComponentForm): max_length=100, required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2409,7 +2421,8 @@ class PowerPortFilterForm(DeviceComponentFilterForm): class PowerPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2446,7 +2459,8 @@ class PowerPortCreateForm(LabeledComponentForm): max_length=100, required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2506,7 +2520,8 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): queryset=PowerPort.objects.all(), required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2550,7 +2565,8 @@ class PowerOutletCreateForm(LabeledComponentForm): max_length=100, required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2709,7 +2725,8 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): }, ) ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2793,7 +2810,8 @@ class InterfaceCreateForm(InterfaceCommonForm, LabeledComponentForm): required=False, widget=StaticSelect2(), ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) untagged_vlan = DynamicModelChoiceField( @@ -3005,7 +3023,8 @@ class FrontPortFilterForm(DeviceComponentFilterForm): class FrontPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3196,7 +3215,8 @@ class RearPortFilterForm(DeviceComponentFilterForm): class RearPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3299,7 +3319,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): class DeviceBayForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3320,7 +3341,8 @@ class DeviceBayCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3350,7 +3372,8 @@ class DeviceBayBulkCreateForm( form_from_model(DeviceBay, ['description', 'tags']), DeviceBulkAddComponentForm ): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3654,7 +3677,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): class CableForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3983,7 +4007,8 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): queryset=Manufacturer.objects.all(), required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -4131,7 +4156,8 @@ class DeviceSelectionForm(forms.Form): class VirtualChassisForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -4321,7 +4347,8 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): queryset=RackGroup.objects.all(), required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -4445,7 +4472,8 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 9222d6081..a6ce89ec4 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -94,7 +94,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'contact_phone': '123-555-9999', 'contact_email': 'hank@stricklandpropane.com', 'comments': 'Test site', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -202,7 +202,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'user': user3.pk, 'tenant': None, 'description': 'Rack reservation', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -268,7 +268,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'outer_depth': 500, 'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER, 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -359,7 +359,7 @@ class DeviceTypeTestCase( 'is_full_depth': True, 'subdevice_role': '', # CharField 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.bulk_edit_data = { @@ -967,7 +967,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'vc_position': None, 'vc_priority': None, 'comments': 'A new device', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), 'local_context_data': None, } @@ -1001,12 +1001,14 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): ConsolePort(device=device, name='Console Port 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Console Port X', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1016,7 +1018,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'label_pattern': 'Serial[3-5]', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1045,12 +1047,14 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): ConsoleServerPort(device=device, name='Console Server Port 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Console Server Port X', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console server port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1058,7 +1062,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'name_pattern': 'Console Server Port [4-6]', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console server port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1087,6 +1091,8 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): PowerPort(device=device, name='Power Port 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Power Port X', @@ -1094,7 +1100,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'maximum_draw': 100, 'allocated_draw': 50, 'description': 'A power port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1104,7 +1110,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'maximum_draw': 100, 'allocated_draw': 50, 'description': 'A power port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1141,6 +1147,8 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Power Outlet X', @@ -1148,7 +1156,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'A power outlet', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1158,7 +1166,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'A power outlet', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1202,6 +1210,8 @@ class InterfaceTestCase( ) VLAN.objects.bulk_create(vlans) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'virtual_machine': None, @@ -1216,7 +1226,7 @@ class InterfaceTestCase( 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1232,7 +1242,7 @@ class InterfaceTestCase( 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1279,6 +1289,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Front Port X', @@ -1286,7 +1298,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'rear_port': rearports[3].pk, 'rear_port_position': 1, 'description': 'New description', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1297,7 +1309,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): '{}:1'.format(rp.pk) for rp in rearports[3:6] ], 'description': 'New description', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1326,13 +1338,15 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): RearPort(device=device, name='Rear Port 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Rear Port X', 'type': PortTypeChoices.TYPE_8P8C, 'positions': 3, 'description': 'A rear port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1341,7 +1355,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'type': PortTypeChoices.TYPE_8P8C, 'positions': 3, 'description': 'A rear port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1373,18 +1387,20 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): DeviceBay(device=device, name='Device Bay 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Device Bay X', 'description': 'A device bay', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { 'device': device.pk, 'name_pattern': 'Device Bay [4-6]', 'description': 'A device bay', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1413,6 +1429,8 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): InventoryItem(device=device, name='Inventory Item 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'manufacturer': manufacturer.pk, @@ -1423,7 +1441,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): 'serial': '123ABC', 'asset_tag': 'ABC123', 'description': 'An inventory item', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1435,7 +1453,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): 'part_id': '123456', 'serial': '123ABC', 'description': 'An inventory item', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1513,7 +1531,7 @@ class CableTestCase( 'color': 'c0c0c0', 'length': 100, 'length_unit': CableLengthUnitChoices.UNIT_FOOT, - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -1626,7 +1644,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'site': sites[1].pk, 'rack_group': rackgroups[1].pk, 'name': 'Power Panel X', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -1680,7 +1698,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'amperage': 100, 'max_utilization': 50, 'comments': 'New comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), # Connection 'cable': None, diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 672b10a78..4e95b389b 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -38,11 +38,10 @@ class NestedGraphSerializer(WritableNestedSerializer): class NestedTagSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') - tagged_items = serializers.IntegerField(read_only=True) class Meta: model = models.Tag - fields = ['id', 'url', 'name', 'slug', 'color', 'tagged_items'] + fields = ['id', 'url', 'name', 'slug', 'color'] class NestedReportResultSerializer(serializers.ModelSerializer): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 54c0650da..5bf664b2f 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -95,6 +95,28 @@ class TagSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'color', 'description', 'tagged_items'] +class TaggedObjectSerializer(serializers.Serializer): + tags = NestedTagSerializer(many=True, required=False) + + def create(self, validated_data): + tags = validated_data.pop('tags', []) + instance = super().create(validated_data) + + return self._save_tags(instance, tags) + + def update(self, instance, validated_data): + tags = validated_data.pop('tags', []) + instance = super().update(instance, validated_data) + + return self._save_tags(instance, tags) + + def _save_tags(self, instance, tags): + if tags: + instance.tags.set(*[t.name for t in tags]) + + return instance + + # # Image attachments # diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index ac4442df4..4b2c1844c 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,8 +1,8 @@ from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.utils.safestring import mark_safe from mptt.forms import TreeNodeMultipleChoiceField -from taggit.forms import TagField as TagField_ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup @@ -142,15 +142,6 @@ class CustomFieldFilterForm(forms.Form): # Tags # -class TagField(TagField_): - - def widget_attrs(self, widget): - # Apply the "tagfield" CSS class to trigger the special API-based selection widget for tags - return { - 'class': 'tagfield' - } - - class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() @@ -161,14 +152,31 @@ class TagForm(BootstrapMixin, forms.ModelForm): ] +class TagCSVForm(CSVModelForm): + slug = SlugField() + + class Meta: + model = Tag + fields = Tag.csv_headers + help_texts = { + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + } + + class AddRemoveTagsForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Add add/remove tags fields - self.fields['add_tags'] = TagField(required=False) - self.fields['remove_tags'] = TagField(required=False) + self.fields['add_tags'] = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + self.fields['remove_tags'] = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class TagFilterForm(BootstrapMixin, forms.Form): diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index a2d43a93b..bd49954c9 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -25,6 +25,8 @@ class Tag(TagBase, ChangeLoggedModel): objects = models.Manager() restricted = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'color', 'description'] + def get_absolute_url(self): return reverse('extras:tag', args=[self.slug]) @@ -35,6 +37,14 @@ class Tag(TagBase, ChangeLoggedModel): slug += "_%d" % i return slug + def to_csv(self): + return ( + self.name, + self.slug, + self.color, + self.description + ) + class TaggedItem(GenericTaggedItemBase): tag = models.ForeignKey( diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 0c1fcd91c..5d7951e54 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -102,7 +102,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): class TagTest(APIViewTestCases.APIViewTestCase): model = Tag - brief_fields = ['color', 'id', 'name', 'slug', 'tagged_items', 'url'] + brief_fields = ['color', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'Tag 4', diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index 50d9c5be6..0c35e0f1a 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -11,7 +11,6 @@ from utilities.testing import APITestCase class ChangeLogTest(APITestCase): def setUp(self): - super().setUp() # Create a custom field on the Site model @@ -31,9 +30,6 @@ class ChangeLogTest(APITestCase): 'custom_fields': { 'my_field': 'ABC' }, - 'tags': [ - 'bar', 'foo' - ], } self.assertEqual(ObjectChange.objects.count(), 0) url = reverse('dcim-api:site-list') @@ -50,7 +46,6 @@ class ChangeLogTest(APITestCase): self.assertEqual(oc.changed_object, site) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE) self.assertEqual(oc.object_data['custom_fields'], data['custom_fields']) - self.assertListEqual(sorted(oc.object_data['tags']), data['tags']) def test_update_object(self): site = Site(name='Test Site 1', slug='test-site-1') @@ -62,9 +57,6 @@ class ChangeLogTest(APITestCase): 'custom_fields': { 'my_field': 'DEF' }, - 'tags': [ - 'abc', 'xyz' - ], } self.assertEqual(ObjectChange.objects.count(), 0) self.add_permissions('dcim.change_site') @@ -81,7 +73,6 @@ class ChangeLogTest(APITestCase): self.assertEqual(oc.changed_object, site) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(oc.object_data['custom_fields'], data['custom_fields']) - self.assertListEqual(sorted(oc.object_data['tags']), data['tags']) def test_delete_object(self): site = Site( @@ -89,7 +80,6 @@ class ChangeLogTest(APITestCase): slug='test-site-1' ) site.save() - site.tags.add('foo', 'bar') CustomFieldValue.objects.create( field=CustomField.objects.get(name='my_field'), obj=site, @@ -108,4 +98,3 @@ class ChangeLogTest(APITestCase): self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'}) - self.assertListEqual(sorted(oc.object_data['tags']), ['bar', 'foo']) diff --git a/netbox/extras/tests/test_tags.py b/netbox/extras/tests/test_tags.py index 8991c58e7..694cd77d9 100644 --- a/netbox/extras/tests/test_tags.py +++ b/netbox/extras/tests/test_tags.py @@ -9,42 +9,53 @@ class TaggedItemTest(APITestCase): """ Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH). """ - - def setUp(self): - - super().setUp() - def test_create_tagged_item(self): + tags = self.create_tags("Foo", "Bar", "Baz") data = { 'name': 'Test Site', 'slug': 'test-site', - 'tags': ['Foo', 'Bar', 'Baz'] + 'tags': [t.pk for t in tags] } url = reverse('dcim-api:site-list') self.add_permissions('dcim.add_site') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) + self.assertListEqual( + sorted([t['id'] for t in response.data['tags']]), + sorted(data['tags']) + ) site = Site.objects.get(pk=response.data['id']) - tags = [tag.name for tag in site.tags.all()] - self.assertEqual(sorted(tags), sorted(data['tags'])) + self.assertListEqual( + sorted([t.name for t in site.tags.all()]), + sorted(["Foo", "Bar", "Baz"]) + ) def test_update_tagged_item(self): site = Site.objects.create( name='Test Site', slug='test-site' ) - site.tags.add('Foo', 'Bar', 'Baz') + site.tags.add("Foo", "Bar", "Baz") + self.create_tags("New Tag") data = { - 'tags': ['Foo', 'Bar', 'New Tag'] + 'tags': [ + {"name": "Foo"}, + {"name": "Bar"}, + {"name": "New Tag"}, + ] } self.add_permissions('dcim.change_site') url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) + self.assertListEqual( + sorted([t['name'] for t in response.data['tags']]), + sorted([t['name'] for t in data['tags']]) + ) site = Site.objects.get(pk=response.data['id']) - tags = [tag.name for tag in site.tags.all()] - self.assertEqual(sorted(tags), sorted(data['tags'])) + self.assertListEqual( + sorted([t.name for t in site.tags.all()]), + sorted(["Foo", "Bar", "New Tag"]) + ) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 9ad29fd80..b3abf5b22 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -10,16 +10,7 @@ from extras.models import ConfigContext, ObjectChange, Tag from utilities.testing import ViewTestCases, TestCase -# TODO: Change base class to PrimaryObjectViewTestCase -# Blocked by #3703 -class TagTestCase( - ViewTestCases.GetObjectViewTestCase, - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, - ViewTestCases.ListObjectsViewTestCase, - ViewTestCases.BulkEditObjectsViewTestCase, - ViewTestCases.BulkDeleteObjectsViewTestCase -): +class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Tag @classmethod @@ -38,6 +29,13 @@ class TagTestCase( 'comments': 'Some comments', } + cls.csv_data = ( + "name,slug,color,description", + "Tag 4,tag-4,ff0000,Fourth tag", + "Tag 5,tag-5,00ff00,Fifth tag", + "Tag 6,tag-6,0000ff,Sixth tag", + ) + cls.bulk_edit_data = { 'color': '00ff00', } diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 3eee303a3..3007e6524 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -9,6 +9,8 @@ urlpatterns = [ # Tags path('tags/', views.TagListView.as_view(), name='tag_list'), + path('tags/add/', views.TagEditView.as_view(), name='tag_add'), + path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), path('tags//', views.TagView.as_view(), name='tag'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 98b82edb9..7fce4fe8e 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -13,14 +13,13 @@ from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.utils import shallow_compare_dict from utilities.views import ( - BulkDeleteView, BulkEditView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin, ) -from . import filters, forms +from . import filters, forms, tables from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .reports import get_report, get_reports from .scripts import get_scripts, run_script -from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable # @@ -35,8 +34,7 @@ class TagListView(ObjectListView): ) filterset = filters.TagFilterSet filterset_form = forms.TagFilterForm - table = TagTable - action_buttons = () + table = tables.TagTable class TagView(ObjectView): @@ -52,7 +50,7 @@ class TagView(ObjectView): ) # Generate a table of all items tagged with this Tag - items_table = TaggedItemTable(tagged_items) + items_table = tables.TaggedItemTable(tagged_items) paginate = { 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) @@ -78,13 +76,20 @@ class TagDeleteView(ObjectDeleteView): default_return_url = 'extras:tag_list' +class TagBulkImportView(BulkImportView): + queryset = Tag.objects.all() + model_form = forms.TagCSVForm + table = tables.TagTable + default_return_url = 'extras:tag_list' + + class TagBulkEditView(BulkEditView): queryset = Tag.restricted.annotate( items=Count('extras_taggeditem_items', distinct=True) ).order_by( 'name' ) - table = TagTable + table = tables.TagTable form = forms.TagBulkEditForm default_return_url = 'extras:tag_list' @@ -95,7 +100,7 @@ class TagBulkDeleteView(BulkDeleteView): ).order_by( 'name' ) - table = TagTable + table = tables.TagTable default_return_url = 'extras:tag_list' @@ -107,7 +112,7 @@ class ConfigContextListView(ObjectListView): queryset = ConfigContext.objects.all() filterset = filters.ConfigContextFilterSet filterset_form = forms.ConfigContextFilterForm - table = ConfigContextTable + table = tables.ConfigContextTable action_buttons = ('add',) @@ -143,7 +148,7 @@ class ConfigContextEditView(ObjectEditView): class ConfigContextBulkEditView(BulkEditView): queryset = ConfigContext.objects.all() filterset = filters.ConfigContextFilterSet - table = ConfigContextTable + table = tables.ConfigContextTable form = forms.ConfigContextBulkEditForm default_return_url = 'extras:configcontext_list' @@ -155,7 +160,7 @@ class ConfigContextDeleteView(ObjectDeleteView): class ConfigContextBulkDeleteView(BulkDeleteView): queryset = ConfigContext.objects.all() - table = ConfigContextTable + table = tables.ConfigContextTable default_return_url = 'extras:configcontext_list' @@ -197,7 +202,7 @@ class ObjectChangeListView(ObjectListView): queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type') filterset = filters.ObjectChangeFilterSet filterset_form = forms.ObjectChangeFilterForm - table = ObjectChangeTable + table = tables.ObjectChangeTable template_name = 'extras/objectchange_list.html' action_buttons = ('export',) @@ -214,7 +219,7 @@ class ObjectChangeView(ObjectView): ).exclude( pk=objectchange.pk ) - related_changes_table = ObjectChangeTable( + related_changes_table = tables.ObjectChangeTable( data=related_changes[:50], orderable=False ) @@ -268,7 +273,7 @@ class ObjectChangeLogView(View): Q(changed_object_type=content_type, changed_object_id=obj.pk) | Q(related_object_type=content_type, related_object_id=obj.pk) ) - objectchanges_table = ObjectChangeTable( + objectchanges_table = tables.ObjectChangeTable( data=objectchanges, orderable=False ) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index f5de2f509..e92006096 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -3,11 +3,11 @@ from collections import OrderedDict from rest_framework import serializers from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.nested_serializers import NestedTenantSerializer @@ -22,9 +22,8 @@ from .nested_serializers import * # VRFs # -class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer): +class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) ipaddress_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) @@ -48,10 +47,9 @@ class RIRSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'is_private', 'description', 'aggregate_count'] -class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): +class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) rir = NestedRIRSerializer() - tags = TagListSerializerField(required=False) class Meta: model = Aggregate @@ -98,13 +96,12 @@ class VLANGroupSerializer(ValidatedModelSerializer): return data -class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): +class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): site = NestedSiteSerializer(required=False, allow_null=True) group = NestedVLANGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=VLANStatusChoices, required=False) role = NestedRoleSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) prefix_count = serializers.IntegerField(read_only=True) class Meta: @@ -133,7 +130,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): # Prefixes # -class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer): +class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) site = NestedSiteSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True) @@ -141,7 +138,6 @@ class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer): vlan = NestedVLANSerializer(required=False, allow_null=True) status = ChoiceField(choices=PrefixStatusChoices, required=False) role = NestedRoleSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) class Meta: model = Prefix @@ -226,7 +222,7 @@ class IPAddressInterfaceSerializer(WritableNestedSerializer): return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request']) -class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): +class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) @@ -235,7 +231,6 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): interface = IPAddressInterfaceSerializer(required=False, allow_null=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_outside = NestedIPAddressSerializer(read_only=True) - tags = TagListSerializerField(required=False) class Meta: model = IPAddress @@ -270,7 +265,7 @@ class AvailableIPSerializer(serializers.Serializer): # Services # -class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer): +class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) @@ -280,7 +275,6 @@ class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer): required=False, many=True ) - tags = TagListSerializerField(required=False) class Meta: model = Service diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index de7c11118..93c1b5777 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -4,8 +4,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator from dcim.models import Device, Interface, Rack, Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, - TagField, ) +from extras.models import Tag from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -33,7 +33,8 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ # class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -141,7 +142,8 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all() ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -292,7 +294,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Role.objects.all(), required=False ) - tags = TagField(required=False) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Prefix @@ -584,7 +589,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel required=False, label='Make this the primary IP for the device/VM' ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -993,7 +999,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Role.objects.all(), required=False ) - tags = TagField(required=False) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = VLAN @@ -1160,7 +1169,8 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm): min_value=SERVICE_PORT_MIN, max_value=SERVICE_PORT_MAX ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index d4d5a857c..b704664c1 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -33,7 +33,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tenant': tenants[0].pk, 'enforce_unique': True, 'description': 'A new VRF', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -100,7 +100,7 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'rir': rirs[1].pk, 'date_added': datetime.date(2020, 1, 1), 'description': 'A new aggregate', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -183,7 +183,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'role': roles[1].pk, 'is_pool': True, 'description': 'A new prefix', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -232,7 +232,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'nat_inside': None, 'dns_name': 'example', 'description': 'A new IP address', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -320,7 +320,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'status': VLANStatusChoices.STATUS_RESERVED, 'role': roles[1].pk, 'description': 'A new VLAN', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -376,7 +376,7 @@ class ServiceTestCase( 'port': 999, 'ipaddresses': [], 'description': 'A new service', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e4670d4b9..bce482214 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -285,7 +285,6 @@ INSTALLED_APPS = [ 'mptt', 'rest_framework', 'taggit', - 'taggit_serializer', 'timezone_field', 'circuits', 'dcim', @@ -489,7 +488,6 @@ SWAGGER_SETTINGS = { 'utilities.custom_inspectors.JSONFieldInspector', 'utilities.custom_inspectors.NullableBooleanFieldInspector', 'utilities.custom_inspectors.CustomChoiceFieldInspector', - 'utilities.custom_inspectors.TagListFieldInspector', 'utilities.custom_inspectors.SerializedPKRelatedFieldInspector', 'drf_yasg.inspectors.CamelCaseJSONFilter', 'drf_yasg.inspectors.ReferencingSerializerInspector', diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 0b73f0002..54132dd34 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -1,8 +1,8 @@ from rest_framework import serializers -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.nested_serializers import NestedDeviceSerializer from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer from secrets.models import Secret, SecretRole from utilities.api import ValidatedModelSerializer from .nested_serializers import * @@ -20,11 +20,10 @@ class SecretRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'description', 'secret_count'] -class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): +class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): device = NestedDeviceSerializer() role = NestedSecretRoleSerializer() plaintext = serializers.CharField() - tags = TagListSerializerField(required=False) class Meta: model = Secret diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 296469900..f62c72293 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -5,8 +5,8 @@ from django import forms from dcim.models import Device from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, - TagField, ) +from extras.models import Tag from utilities.forms import ( APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect2Multiple, TagFilterField, @@ -90,7 +90,8 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): role = DynamicModelChoiceField( queryset=SecretRole.objects.all() ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 0c20bcbdc..ff54a4800 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -85,7 +85,7 @@ Description - {{ tag.description }} + {{ tag.description|placeholder }} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index c862b1faa..db8442821 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -102,6 +102,12 @@
  • + {% if perms.extras.add_tag %} +
    + + +
    + {% endif %} Tags diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 9c7a099e4..4454ac776 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer from tenancy.models import Tenant, TenantGroup from utilities.api import ValidatedModelSerializer from .nested_serializers import * @@ -20,9 +20,8 @@ class TenantGroupSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'parent', 'description', 'tenant_count'] -class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): +class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): group = NestedTenantGroupSerializer(required=False) - tags = TagListSerializerField(required=False) circuit_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) ipaddress_count = serializers.IntegerField(read_only=True) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index bf100f43a..5bd0657b6 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,8 +2,8 @@ from django import forms from extras.forms import ( AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm, - TagField, ) +from extras.models import Tag from utilities.forms import ( APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField, @@ -57,7 +57,8 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index ca2c2633f..4e00b648c 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -55,7 +55,7 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'group': tenant_groups[1].pk, 'description': 'A new tenant', 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 2cbe1cfc5..14463de23 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -1,10 +1,9 @@ from django.contrib.postgres.fields import JSONField from drf_yasg import openapi -from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector, SwaggerAutoSchema +from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, SwaggerAutoSchema from drf_yasg.utils import get_serializer_ref_name from rest_framework.fields import ChoiceField from rest_framework.relations import ManyRelatedField -from taggit_serializer.serializers import TagListSerializerField from dcim.api.serializers import InterfaceSerializer as DeviceInterfaceSerializer from extras.api.customfields import CustomFieldsSerializer @@ -56,19 +55,6 @@ class SerializedPKRelatedFieldInspector(FieldInspector): return NotHandled -class TagListFieldInspector(FieldInspector): - def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): - SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) - if isinstance(field, TagListSerializerField): - child_schema = self.probe_field_inspectors(field.child, ChildSwaggerType, use_references) - return SwaggerType( - type=openapi.TYPE_ARRAY, - items=child_schema, - ) - - return NotHandled - - class CustomChoiceFieldInspector(FieldInspector): def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): # this returns a callable which extracts title, description and other stuff diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 558e926a9..04bc4c542 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -12,6 +12,9 @@ class DummyQuerySet: def __init__(self, queryset): self._cache = [obj for obj in queryset.all()] + def __iter__(self): + return iter(self._cache) + def all(self): return self._cache diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index fd8c70f05..d763012f0 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -14,7 +14,14 @@ def post_data(data): if value is None: ret[key] = '' elif type(value) in (list, tuple): - ret[key] = value + if value and hasattr(value[0], 'pk'): + # Value is a list of instances + ret[key] = [v.pk for v in value] + else: + ret[key] = value + elif hasattr(value, 'pk'): + # Value is an instance + ret[key] = value.pk else: ret[key] = str(value) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index b3b98605d..e3c1f0720 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -6,8 +6,10 @@ from django.db.models import ForeignKey, ManyToManyField from django.forms.models import model_to_dict from django.test import Client, TestCase as _TestCase, override_settings from django.urls import reverse, NoReverseMatch +from django.utils.text import slugify from netaddr import IPNetwork +from extras.models import Tag from users.models import ObjectPermission from utilities.permissions import resolve_permission_ct from .utils import disable_warnings, post_data @@ -49,7 +51,7 @@ class TestCase(_TestCase): obj_perm.object_types.add(ct) # - # Convenience methods + # Custom assertions # def assertHttpStatus(self, response, expected_status): @@ -75,7 +77,7 @@ class TestCase(_TestCase): # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext) if key == 'tags': - model_dict[key] = ','.join(sorted([tag.name for tag in value])) + model_dict[key] = sorted(value) # Convert ManyToManyField to list of instance PKs elif model_dict[key] and type(value) in (list, tuple) and hasattr(value[0], 'pk'): @@ -108,6 +110,19 @@ class TestCase(_TestCase): self.assertDictEqual(model_dict, relevant_data) + # + # Convenience methods + # + + @classmethod + def create_tags(cls, *names): + """ + Create and return a Tag instance for each name given. + """ + tags = [Tag(name=name, slug=slugify(name)) for name in names] + Tag.objects.bulk_create(tags) + return tags + # # UI Tests diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 3cca95b22..008c6dd88 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,11 +1,11 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer -from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices +from dcim.choices import InterfaceModeChoices from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN from tenancy.api.nested_serializers import NestedTenantSerializer @@ -35,12 +35,11 @@ class ClusterGroupSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'description', 'cluster_count'] -class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): +class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -56,7 +55,7 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): # Virtual machines # -class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer): +class VirtualMachineSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) site = NestedSiteSerializer(read_only=True) cluster = NestedClusterSerializer() @@ -66,7 +65,6 @@ class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer): primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) class Meta: model = VirtualMachine @@ -97,7 +95,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): # VM interfaces # -class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): +class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) @@ -108,7 +106,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): required=False, many=True ) - tags = TagListSerializerField(required=False) class Meta: model = Interface diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 2f2ee4950..942368f19 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -7,8 +7,8 @@ from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, - TagField, ) +from extras.models import Tag from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant @@ -83,7 +83,8 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -312,13 +313,14 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Platform.objects.all(), required=False ) - tags = TagField( - required=False - ) local_context_data = JSONField( required=False, label='' ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = VirtualMachine @@ -590,7 +592,8 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): }, ) ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -697,7 +700,8 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): }, ) ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index b6f5be8b2..0ccf8a9b1 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -97,7 +97,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tenant': None, 'site': sites[1].pk, 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -161,7 +161,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'memory': 32768, 'disk': 4000, 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), 'local_context_data': None, } @@ -228,6 +228,8 @@ class InterfaceTestCase( ) VLAN.objects.bulk_create(vlans) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'virtual_machine': virtualmachines[1].pk, 'name': 'Interface X', @@ -240,7 +242,7 @@ class InterfaceTestCase( 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -255,7 +257,7 @@ class InterfaceTestCase( 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { diff --git a/requirements.txt b/requirements.txt index 79e4fdd9f..eac5ca9d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,6 @@ django-prometheus==2.0.0 django-rq==2.3.2 django-tables2==2.3.1 django-taggit==1.2.0 -django-taggit-serializer==0.1.7 django-timezone-field==4.0 djangorestframework==3.11.0 drf-yasg[validation]==1.17.1