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

Closes #3703: Restrict tag creation
This commit is contained in:
Jeremy Stretch 2020-06-17 12:28:04 -04:00 committed by GitHub
commit 2d4694e72d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 349 additions and 250 deletions

View File

@ -42,10 +42,6 @@ django-tables2
# https://github.com/alex/django-taggit # https://github.com/alex/django-taggit
django-taggit django-taggit
# A Django REST Framework serializer which represents tags
# https://github.com/glemmaPaul/django-taggit-serializer
django-taggit-serializer
# A Django field for representing time zones # A Django field for representing time zones
# https://github.com/mfogel/django-timezone-field/ # https://github.com/mfogel/django-timezone-field/
django-timezone-field django-timezone-field

View File

@ -10,6 +10,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
### Enhancements ### Enhancements
* [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object
* [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components * [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components
* [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations * [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations
@ -18,6 +19,20 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
* If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.) * If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`. * `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
### REST API Changes
* The count of `tagged_items` is no longer included when viewing the tags list when `brief` is passed.
* The assignment of tags to an object is now achieved in the same manner as specifying any other related device. The `tags` field accepts a list of JSON objects each matching a desired tag. (Alternatively, a list of numeric primary keys corresponding to tags may be passed instead.) For example:
```json
"tags": [
{"name": "First Tag"},
{"name": "Second Tag"}
]
```
* The `tags` field of an object now includes a more complete representation of each tag, rather than just its name.
### Other Changes ### Other Changes
* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey. * The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey.

View File

@ -1,11 +1,11 @@
from rest_framework import serializers from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from circuits.choices import CircuitStatusChoices from circuits.choices import CircuitStatusChoices
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
from dcim.api.serializers import ConnectedEndpointSerializer from dcim.api.serializers import ConnectedEndpointSerializer
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
from .nested_serializers import * from .nested_serializers import *
@ -15,8 +15,7 @@ from .nested_serializers import *
# Providers # Providers
# #
class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer): class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
tags = TagListSerializerField(required=False)
circuit_count = serializers.IntegerField(read_only=True) circuit_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
@ -49,14 +48,13 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id'] fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
provider = NestedProviderSerializer() provider = NestedProviderSerializer()
status = ChoiceField(choices=CircuitStatusChoices, required=False) status = ChoiceField(choices=CircuitStatusChoices, required=False)
type = NestedCircuitTypeSerializer() type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
termination_a = CircuitCircuitTerminationSerializer(read_only=True) termination_a = CircuitCircuitTerminationSerializer(read_only=True)
termination_z = CircuitCircuitTerminationSerializer(read_only=True) termination_z = CircuitCircuitTerminationSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = Circuit model = Circuit

View File

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

View File

@ -26,7 +26,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'noc_contact': 'noc@example.com', 'noc_contact': 'noc@example.com',
'admin_contact': 'admin@example.com', 'admin_contact': 'admin@example.com',
'comments': 'Another provider', 'comments': 'Another provider',
'tags': 'Alpha,Bravo,Charlie', 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
} }
cls.csv_data = ( cls.csv_data = (
@ -106,7 +106,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'commit_rate': 1000, 'commit_rate': 1000,
'description': 'A new circuit', 'description': 'A new circuit',
'comments': 'Some comments', 'comments': 'Some comments',
'tags': 'Alpha,Bravo,Charlie', 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'),
} }
cls.csv_data = ( cls.csv_data = (
@ -124,5 +124,4 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'commit_rate': 2000, 'commit_rate': 2000,
'description': 'New description', 'description': 'New description',
'comments': 'New comments', 'comments': 'New comments',
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -95,6 +95,28 @@ class TagSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'color', 'description', 'tagged_items'] fields = ['id', 'name', 'slug', 'color', 'description', 'tagged_items']
class TaggedObjectSerializer(serializers.Serializer):
tags = NestedTagSerializer(many=True, required=False)
def create(self, validated_data):
tags = validated_data.pop('tags', [])
instance = super().create(validated_data)
return self._save_tags(instance, tags)
def update(self, instance, validated_data):
tags = validated_data.pop('tags', [])
instance = super().update(instance, validated_data)
return self._save_tags(instance, tags)
def _save_tags(self, instance, tags):
if tags:
instance.tags.set(*[t.name for t in tags])
return instance
# #
# Image attachments # Image attachments
# #

View File

@ -1,8 +1,8 @@
from django import forms from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from mptt.forms import TreeNodeMultipleChoiceField from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField as TagField_
from dcim.models import DeviceRole, Platform, Region, Site from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -142,15 +142,6 @@ class CustomFieldFilterForm(forms.Form):
# Tags # Tags
# #
class TagField(TagField_):
def widget_attrs(self, widget):
# Apply the "tagfield" CSS class to trigger the special API-based selection widget for tags
return {
'class': 'tagfield'
}
class TagForm(BootstrapMixin, forms.ModelForm): class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField() slug = SlugField()
@ -161,14 +152,31 @@ class TagForm(BootstrapMixin, forms.ModelForm):
] ]
class TagCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
model = Tag
fields = Tag.csv_headers
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
class AddRemoveTagsForm(forms.Form): class AddRemoveTagsForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Add add/remove tags fields # Add add/remove tags fields
self.fields['add_tags'] = TagField(required=False) self.fields['add_tags'] = DynamicModelMultipleChoiceField(
self.fields['remove_tags'] = TagField(required=False) queryset=Tag.objects.all(),
required=False
)
self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class TagFilterForm(BootstrapMixin, forms.Form): class TagFilterForm(BootstrapMixin, forms.Form):

View File

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

View File

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

View File

@ -11,7 +11,6 @@ from utilities.testing import APITestCase
class ChangeLogTest(APITestCase): class ChangeLogTest(APITestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
# Create a custom field on the Site model # Create a custom field on the Site model
@ -31,9 +30,6 @@ class ChangeLogTest(APITestCase):
'custom_fields': { 'custom_fields': {
'my_field': 'ABC' 'my_field': 'ABC'
}, },
'tags': [
'bar', 'foo'
],
} }
self.assertEqual(ObjectChange.objects.count(), 0) self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
@ -50,7 +46,6 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.changed_object, site) self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields']) self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
def test_update_object(self): def test_update_object(self):
site = Site(name='Test Site 1', slug='test-site-1') site = Site(name='Test Site 1', slug='test-site-1')
@ -62,9 +57,6 @@ class ChangeLogTest(APITestCase):
'custom_fields': { 'custom_fields': {
'my_field': 'DEF' 'my_field': 'DEF'
}, },
'tags': [
'abc', 'xyz'
],
} }
self.assertEqual(ObjectChange.objects.count(), 0) self.assertEqual(ObjectChange.objects.count(), 0)
self.add_permissions('dcim.change_site') self.add_permissions('dcim.change_site')
@ -81,7 +73,6 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.changed_object, site) self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields']) self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
def test_delete_object(self): def test_delete_object(self):
site = Site( site = Site(
@ -89,7 +80,6 @@ class ChangeLogTest(APITestCase):
slug='test-site-1' slug='test-site-1'
) )
site.save() site.save()
site.tags.add('foo', 'bar')
CustomFieldValue.objects.create( CustomFieldValue.objects.create(
field=CustomField.objects.get(name='my_field'), field=CustomField.objects.get(name='my_field'),
obj=site, obj=site,
@ -108,4 +98,3 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'}) self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
self.assertListEqual(sorted(oc.object_data['tags']), ['bar', 'foo'])

View File

@ -9,42 +9,53 @@ class TaggedItemTest(APITestCase):
""" """
Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH). Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
""" """
def setUp(self):
super().setUp()
def test_create_tagged_item(self): def test_create_tagged_item(self):
tags = self.create_tags("Foo", "Bar", "Baz")
data = { data = {
'name': 'Test Site', 'name': 'Test Site',
'slug': 'test-site', 'slug': 'test-site',
'tags': ['Foo', 'Bar', 'Baz'] 'tags': [t.pk for t in tags]
} }
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site') self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) self.assertListEqual(
sorted([t['id'] for t in response.data['tags']]),
sorted(data['tags'])
)
site = Site.objects.get(pk=response.data['id']) site = Site.objects.get(pk=response.data['id'])
tags = [tag.name for tag in site.tags.all()] self.assertListEqual(
self.assertEqual(sorted(tags), sorted(data['tags'])) sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "Baz"])
)
def test_update_tagged_item(self): def test_update_tagged_item(self):
site = Site.objects.create( site = Site.objects.create(
name='Test Site', name='Test Site',
slug='test-site' slug='test-site'
) )
site.tags.add('Foo', 'Bar', 'Baz') site.tags.add("Foo", "Bar", "Baz")
self.create_tags("New Tag")
data = { data = {
'tags': ['Foo', 'Bar', 'New Tag'] 'tags': [
{"name": "Foo"},
{"name": "Bar"},
{"name": "New Tag"},
]
} }
self.add_permissions('dcim.change_site') self.add_permissions('dcim.change_site')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) self.assertListEqual(
sorted([t['name'] for t in response.data['tags']]),
sorted([t['name'] for t in data['tags']])
)
site = Site.objects.get(pk=response.data['id']) site = Site.objects.get(pk=response.data['id'])
tags = [tag.name for tag in site.tags.all()] self.assertListEqual(
self.assertEqual(sorted(tags), sorted(data['tags'])) sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "New Tag"])
)

View File

@ -10,16 +10,7 @@ from extras.models import ConfigContext, ObjectChange, Tag
from utilities.testing import ViewTestCases, TestCase from utilities.testing import ViewTestCases, TestCase
# TODO: Change base class to PrimaryObjectViewTestCase class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
# Blocked by #3703
class TagTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = Tag model = Tag
@classmethod @classmethod
@ -38,6 +29,13 @@ class TagTestCase(
'comments': 'Some comments', 'comments': 'Some comments',
} }
cls.csv_data = (
"name,slug,color,description",
"Tag 4,tag-4,ff0000,Fourth tag",
"Tag 5,tag-5,00ff00,Fifth tag",
"Tag 6,tag-6,0000ff,Sixth tag",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'color': '00ff00', 'color': '00ff00',
} }

View File

@ -9,6 +9,8 @@ urlpatterns = [
# Tags # Tags
path('tags/', views.TagListView.as_view(), name='tag_list'), path('tags/', views.TagListView.as_view(), name='tag_list'),
path('tags/add/', views.TagEditView.as_view(), name='tag_add'),
path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'),
path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path('tags/<str:slug>/', views.TagView.as_view(), name='tag'), path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),

View File

@ -13,14 +13,13 @@ from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.utils import shallow_compare_dict from utilities.utils import shallow_compare_dict
from utilities.views import ( from utilities.views import (
BulkDeleteView, BulkEditView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
ObjectPermissionRequiredMixin, ObjectPermissionRequiredMixin,
) )
from . import filters, forms from . import filters, forms, tables
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
from .reports import get_report, get_reports from .reports import get_report, get_reports
from .scripts import get_scripts, run_script from .scripts import get_scripts, run_script
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
# #
@ -35,8 +34,7 @@ class TagListView(ObjectListView):
) )
filterset = filters.TagFilterSet filterset = filters.TagFilterSet
filterset_form = forms.TagFilterForm filterset_form = forms.TagFilterForm
table = TagTable table = tables.TagTable
action_buttons = ()
class TagView(ObjectView): class TagView(ObjectView):
@ -52,7 +50,7 @@ class TagView(ObjectView):
) )
# Generate a table of all items tagged with this Tag # Generate a table of all items tagged with this Tag
items_table = TaggedItemTable(tagged_items) items_table = tables.TaggedItemTable(tagged_items)
paginate = { paginate = {
'paginator_class': EnhancedPaginator, 'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
@ -78,13 +76,20 @@ class TagDeleteView(ObjectDeleteView):
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
class TagBulkImportView(BulkImportView):
queryset = Tag.objects.all()
model_form = forms.TagCSVForm
table = tables.TagTable
default_return_url = 'extras:tag_list'
class TagBulkEditView(BulkEditView): class TagBulkEditView(BulkEditView):
queryset = Tag.restricted.annotate( queryset = Tag.restricted.annotate(
items=Count('extras_taggeditem_items', distinct=True) items=Count('extras_taggeditem_items', distinct=True)
).order_by( ).order_by(
'name' 'name'
) )
table = TagTable table = tables.TagTable
form = forms.TagBulkEditForm form = forms.TagBulkEditForm
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
@ -95,7 +100,7 @@ class TagBulkDeleteView(BulkDeleteView):
).order_by( ).order_by(
'name' 'name'
) )
table = TagTable table = tables.TagTable
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
@ -107,7 +112,7 @@ class ConfigContextListView(ObjectListView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filterset = filters.ConfigContextFilterSet filterset = filters.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm filterset_form = forms.ConfigContextFilterForm
table = ConfigContextTable table = tables.ConfigContextTable
action_buttons = ('add',) action_buttons = ('add',)
@ -143,7 +148,7 @@ class ConfigContextEditView(ObjectEditView):
class ConfigContextBulkEditView(BulkEditView): class ConfigContextBulkEditView(BulkEditView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filterset = filters.ConfigContextFilterSet filterset = filters.ConfigContextFilterSet
table = ConfigContextTable table = tables.ConfigContextTable
form = forms.ConfigContextBulkEditForm form = forms.ConfigContextBulkEditForm
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
@ -155,7 +160,7 @@ class ConfigContextDeleteView(ObjectDeleteView):
class ConfigContextBulkDeleteView(BulkDeleteView): class ConfigContextBulkDeleteView(BulkDeleteView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
table = ConfigContextTable table = tables.ConfigContextTable
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
@ -197,7 +202,7 @@ class ObjectChangeListView(ObjectListView):
queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type') queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
filterset = filters.ObjectChangeFilterSet filterset = filters.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm filterset_form = forms.ObjectChangeFilterForm
table = ObjectChangeTable table = tables.ObjectChangeTable
template_name = 'extras/objectchange_list.html' template_name = 'extras/objectchange_list.html'
action_buttons = ('export',) action_buttons = ('export',)
@ -214,7 +219,7 @@ class ObjectChangeView(ObjectView):
).exclude( ).exclude(
pk=objectchange.pk pk=objectchange.pk
) )
related_changes_table = ObjectChangeTable( related_changes_table = tables.ObjectChangeTable(
data=related_changes[:50], data=related_changes[:50],
orderable=False orderable=False
) )
@ -268,7 +273,7 @@ class ObjectChangeLogView(View):
Q(changed_object_type=content_type, changed_object_id=obj.pk) | Q(changed_object_type=content_type, changed_object_id=obj.pk) |
Q(related_object_type=content_type, related_object_id=obj.pk) Q(related_object_type=content_type, related_object_id=obj.pk)
) )
objectchanges_table = ObjectChangeTable( objectchanges_table = tables.ObjectChangeTable(
data=objectchanges, data=objectchanges,
orderable=False orderable=False
) )

View File

@ -3,11 +3,11 @@ from collections import OrderedDict
from rest_framework import serializers from rest_framework import serializers
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
from dcim.models import Interface from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from ipam.choices import * from ipam.choices import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
@ -22,9 +22,8 @@ from .nested_serializers import *
# VRFs # VRFs
# #
class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer): class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
ipaddress_count = serializers.IntegerField(read_only=True) ipaddress_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True)
@ -48,10 +47,9 @@ class RIRSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'is_private', 'description', 'aggregate_count'] fields = ['id', 'name', 'slug', 'is_private', 'description', 'aggregate_count']
class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
rir = NestedRIRSerializer() rir = NestedRIRSerializer()
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = Aggregate model = Aggregate
@ -98,13 +96,12 @@ class VLANGroupSerializer(ValidatedModelSerializer):
return data return data
class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True)
group = NestedVLANGroupSerializer(required=False, allow_null=True) group = NestedVLANGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=VLANStatusChoices, required=False) status = ChoiceField(choices=VLANStatusChoices, required=False)
role = NestedRoleSerializer(required=False, allow_null=True) role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
prefix_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
@ -133,7 +130,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
# Prefixes # Prefixes
# #
class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer): class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
site = NestedSiteSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True)
vrf = NestedVRFSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True)
@ -141,7 +138,6 @@ class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
vlan = NestedVLANSerializer(required=False, allow_null=True) vlan = NestedVLANSerializer(required=False, allow_null=True)
status = ChoiceField(choices=PrefixStatusChoices, required=False) status = ChoiceField(choices=PrefixStatusChoices, required=False)
role = NestedRoleSerializer(required=False, allow_null=True) role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = Prefix model = Prefix
@ -226,7 +222,7 @@ class IPAddressInterfaceSerializer(WritableNestedSerializer):
return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request']) return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
vrf = NestedVRFSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
@ -235,7 +231,6 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
interface = IPAddressInterfaceSerializer(required=False, allow_null=True) interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
nat_outside = NestedIPAddressSerializer(read_only=True) nat_outside = NestedIPAddressSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = IPAddress model = IPAddress
@ -270,7 +265,7 @@ class AvailableIPSerializer(serializers.Serializer):
# Services # Services
# #
class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer): class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
device = NestedDeviceSerializer(required=False, allow_null=True) device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
@ -280,7 +275,6 @@ class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
required=False, required=False,
many=True many=True
) )
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = Service model = Service

View File

@ -4,8 +4,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from dcim.models import Device, Interface, Rack, Region, Site from dcim.models import Device, Interface, Rack, Region, Site
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
TagField,
) )
from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
@ -33,7 +33,8 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
# #
class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -141,7 +142,8 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
rir = DynamicModelChoiceField( rir = DynamicModelChoiceField(
queryset=RIR.objects.all() queryset=RIR.objects.all()
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -292,7 +294,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False required=False
) )
tags = TagField(required=False) tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Prefix model = Prefix
@ -584,7 +589,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
required=False, required=False,
label='Make this the primary IP for the device/VM' label='Make this the primary IP for the device/VM'
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -993,7 +999,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False required=False
) )
tags = TagField(required=False) tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = VLAN model = VLAN
@ -1160,7 +1169,8 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
min_value=SERVICE_PORT_MIN, min_value=SERVICE_PORT_MIN,
max_value=SERVICE_PORT_MAX max_value=SERVICE_PORT_MAX
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -102,6 +102,12 @@
<li class="divider"></li> <li class="divider"></li>
<li class="dropdown-header">Tags</li> <li class="dropdown-header">Tags</li>
<li{% if not perms.extras.view_tag %} class="disabled"{% endif %}> <li{% if not perms.extras.view_tag %} class="disabled"{% endif %}>
{% if perms.extras.add_tag %}
<div class="buttons pull-right">
<a href="{% url 'extras:tag_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'extras:tag_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'extras:tag_list' %}">Tags</a> <a href="{% url 'extras:tag_list' %}">Tags</a>
</li> </li>
</ul> </ul>

View File

@ -1,7 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.api import ValidatedModelSerializer from utilities.api import ValidatedModelSerializer
from .nested_serializers import * from .nested_serializers import *
@ -20,9 +20,8 @@ class TenantGroupSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'parent', 'description', 'tenant_count'] fields = ['id', 'name', 'slug', 'parent', 'description', 'tenant_count']
class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
group = NestedTenantGroupSerializer(required=False) group = NestedTenantGroupSerializer(required=False)
tags = TagListSerializerField(required=False)
circuit_count = serializers.IntegerField(read_only=True) circuit_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
ipaddress_count = serializers.IntegerField(read_only=True) ipaddress_count = serializers.IntegerField(read_only=True)

View File

@ -2,8 +2,8 @@ from django import forms
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm, AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
TagField,
) )
from extras.models import Tag
from utilities.forms import ( from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField,
@ -57,7 +57,8 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
required=False required=False
) )
comments = CommentField() comments = CommentField()
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )

View File

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

View File

@ -1,10 +1,9 @@
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from drf_yasg import openapi from drf_yasg import openapi
from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector, SwaggerAutoSchema from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, SwaggerAutoSchema
from drf_yasg.utils import get_serializer_ref_name from drf_yasg.utils import get_serializer_ref_name
from rest_framework.fields import ChoiceField from rest_framework.fields import ChoiceField
from rest_framework.relations import ManyRelatedField from rest_framework.relations import ManyRelatedField
from taggit_serializer.serializers import TagListSerializerField
from dcim.api.serializers import InterfaceSerializer as DeviceInterfaceSerializer from dcim.api.serializers import InterfaceSerializer as DeviceInterfaceSerializer
from extras.api.customfields import CustomFieldsSerializer from extras.api.customfields import CustomFieldsSerializer
@ -56,19 +55,6 @@ class SerializedPKRelatedFieldInspector(FieldInspector):
return NotHandled return NotHandled
class TagListFieldInspector(FieldInspector):
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
if isinstance(field, TagListSerializerField):
child_schema = self.probe_field_inspectors(field.child, ChildSwaggerType, use_references)
return SwaggerType(
type=openapi.TYPE_ARRAY,
items=child_schema,
)
return NotHandled
class CustomChoiceFieldInspector(FieldInspector): class CustomChoiceFieldInspector(FieldInspector):
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
# this returns a callable which extracts title, description and other stuff # this returns a callable which extracts title, description and other stuff

View File

@ -12,6 +12,9 @@ class DummyQuerySet:
def __init__(self, queryset): def __init__(self, queryset):
self._cache = [obj for obj in queryset.all()] self._cache = [obj for obj in queryset.all()]
def __iter__(self):
return iter(self._cache)
def all(self): def all(self):
return self._cache return self._cache

View File

@ -14,7 +14,14 @@ def post_data(data):
if value is None: if value is None:
ret[key] = '' ret[key] = ''
elif type(value) in (list, tuple): elif type(value) in (list, tuple):
ret[key] = value if value and hasattr(value[0], 'pk'):
# Value is a list of instances
ret[key] = [v.pk for v in value]
else:
ret[key] = value
elif hasattr(value, 'pk'):
# Value is an instance
ret[key] = value.pk
else: else:
ret[key] = str(value) ret[key] = str(value)

View File

@ -6,8 +6,10 @@ from django.db.models import ForeignKey, ManyToManyField
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django.test import Client, TestCase as _TestCase, override_settings from django.test import Client, TestCase as _TestCase, override_settings
from django.urls import reverse, NoReverseMatch from django.urls import reverse, NoReverseMatch
from django.utils.text import slugify
from netaddr import IPNetwork from netaddr import IPNetwork
from extras.models import Tag
from users.models import ObjectPermission from users.models import ObjectPermission
from utilities.permissions import resolve_permission_ct from utilities.permissions import resolve_permission_ct
from .utils import disable_warnings, post_data from .utils import disable_warnings, post_data
@ -49,7 +51,7 @@ class TestCase(_TestCase):
obj_perm.object_types.add(ct) obj_perm.object_types.add(ct)
# #
# Convenience methods # Custom assertions
# #
def assertHttpStatus(self, response, expected_status): def assertHttpStatus(self, response, expected_status):
@ -75,7 +77,7 @@ class TestCase(_TestCase):
# TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext) # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
if key == 'tags': if key == 'tags':
model_dict[key] = ','.join(sorted([tag.name for tag in value])) model_dict[key] = sorted(value)
# Convert ManyToManyField to list of instance PKs # Convert ManyToManyField to list of instance PKs
elif model_dict[key] and type(value) in (list, tuple) and hasattr(value[0], 'pk'): elif model_dict[key] and type(value) in (list, tuple) and hasattr(value[0], 'pk'):
@ -108,6 +110,19 @@ class TestCase(_TestCase):
self.assertDictEqual(model_dict, relevant_data) self.assertDictEqual(model_dict, relevant_data)
#
# Convenience methods
#
@classmethod
def create_tags(cls, *names):
"""
Create and return a Tag instance for each name given.
"""
tags = [Tag(name=name, slug=slugify(name)) for name in names]
Tag.objects.bulk_create(tags)
return tags
# #
# UI Tests # UI Tests

View File

@ -1,11 +1,11 @@
from drf_yasg.utils import swagger_serializer_method from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import Interface from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN from ipam.models import VLAN
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
@ -35,12 +35,11 @@ class ClusterGroupSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'description', 'cluster_count'] fields = ['id', 'name', 'slug', 'description', 'cluster_count']
class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
type = NestedClusterTypeSerializer() type = NestedClusterTypeSerializer()
group = NestedClusterGroupSerializer(required=False, allow_null=True) group = NestedClusterGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
site = NestedSiteSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True)
@ -56,7 +55,7 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
# Virtual machines # Virtual machines
# #
class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer): class VirtualMachineSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
site = NestedSiteSerializer(read_only=True) site = NestedSiteSerializer(read_only=True)
cluster = NestedClusterSerializer() cluster = NestedClusterSerializer()
@ -66,7 +65,6 @@ class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
@ -97,7 +95,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
# VM interfaces # VM interfaces
# #
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
virtual_machine = NestedVirtualMachineSerializer() virtual_machine = NestedVirtualMachineSerializer()
type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False) type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
@ -108,7 +106,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
required=False, required=False,
many=True many=True
) )
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = Interface model = Interface

View File

@ -7,8 +7,8 @@ from dcim.forms import INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
TagField,
) )
from extras.models import Tag
from ipam.models import IPAddress, VLAN from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
@ -83,7 +83,8 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
required=False required=False
) )
comments = CommentField() comments = CommentField()
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -312,13 +313,14 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False required=False
) )
tags = TagField(
required=False
)
local_context_data = JSONField( local_context_data = JSONField(
required=False, required=False,
label='' label=''
) )
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
@ -590,7 +592,8 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
}, },
) )
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -697,7 +700,8 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
}, },
) )
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )

View File

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

View File

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