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
django-taggit
# A Django REST Framework serializer which represents tags
# https://github.com/glemmaPaul/django-taggit-serializer
django-taggit-serializer
# A Django field for representing time zones
# https://github.com/mfogel/django-timezone-field/
django-timezone-field

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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).
"""
def setUp(self):
super().setUp()
def test_create_tagged_item(self):
tags = self.create_tags("Foo", "Bar", "Baz")
data = {
'name': 'Test Site',
'slug': 'test-site',
'tags': ['Foo', 'Bar', 'Baz']
'tags': [t.pk for t in tags]
}
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(sorted(response.data['tags']), sorted(data['tags']))
self.assertListEqual(
sorted([t['id'] for t in response.data['tags']]),
sorted(data['tags'])
)
site = Site.objects.get(pk=response.data['id'])
tags = [tag.name for tag in site.tags.all()]
self.assertEqual(sorted(tags), sorted(data['tags']))
self.assertListEqual(
sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "Baz"])
)
def test_update_tagged_item(self):
site = Site.objects.create(
name='Test Site',
slug='test-site'
)
site.tags.add('Foo', 'Bar', 'Baz')
site.tags.add("Foo", "Bar", "Baz")
self.create_tags("New Tag")
data = {
'tags': ['Foo', 'Bar', 'New Tag']
'tags': [
{"name": "Foo"},
{"name": "Bar"},
{"name": "New Tag"},
]
}
self.add_permissions('dcim.change_site')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(sorted(response.data['tags']), sorted(data['tags']))
self.assertListEqual(
sorted([t['name'] for t in response.data['tags']]),
sorted([t['name'] for t in data['tags']])
)
site = Site.objects.get(pk=response.data['id'])
tags = [tag.name for tag in site.tags.all()]
self.assertEqual(sorted(tags), sorted(data['tags']))
self.assertListEqual(
sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "New Tag"])
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -102,6 +102,12 @@
<li class="divider"></li>
<li class="dropdown-header">Tags</li>
<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>
</li>
</ul>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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