diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index d0bdb09a7..4976e2d9b 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -18,7 +18,7 @@ class ProviderCSVForm(NetBoxModelCSVForm): class Meta: model = Provider fields = ( - 'name', 'slug', 'account', 'description', 'comments', + 'name', 'slug', 'account', 'description', 'comments', 'tags', ) @@ -32,7 +32,7 @@ class ProviderNetworkCSVForm(NetBoxModelCSVForm): class Meta: model = ProviderNetwork fields = [ - 'provider', 'name', 'service_id', 'description', 'comments', + 'provider', 'name', 'service_id', 'description', 'comments', 'tags' ] @@ -41,7 +41,7 @@ class CircuitTypeCSVForm(NetBoxModelCSVForm): class Meta: model = CircuitType - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') help_texts = { 'name': 'Name of circuit type', } @@ -73,5 +73,5 @@ class CircuitCSVForm(NetBoxModelCSVForm): model = Circuit fields = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', - 'description', 'comments', + 'description', 'comments', 'tags' ] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 4c90c9c02..2b77ef5a9 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -56,7 +56,7 @@ class RegionCSVForm(NetBoxModelCSVForm): class Meta: model = Region - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class SiteGroupCSVForm(NetBoxModelCSVForm): @@ -100,7 +100,7 @@ class SiteCSVForm(NetBoxModelCSVForm): model = Site fields = ( 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags' ) help_texts = { 'time_zone': mark_safe( @@ -137,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description') + fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags') class RackRoleCSVForm(NetBoxModelCSVForm): @@ -145,7 +145,7 @@ class RackRoleCSVForm(NetBoxModelCSVForm): class Meta: model = RackRole - fields = ('name', 'slug', 'color', 'description') + fields = ('name', 'slug', 'color', 'description', 'tags') help_texts = { 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), } @@ -197,7 +197,7 @@ class RackCSVForm(NetBoxModelCSVForm): fields = ( 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', - 'description', 'comments', + 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -241,7 +241,7 @@ class RackReservationCSVForm(NetBoxModelCSVForm): class Meta: model = RackReservation - fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments') + fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments', 'tags') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -264,7 +264,7 @@ class ManufacturerCSVForm(NetBoxModelCSVForm): class Meta: model = Manufacturer - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') class DeviceRoleCSVForm(NetBoxModelCSVForm): @@ -272,7 +272,7 @@ class DeviceRoleCSVForm(NetBoxModelCSVForm): class Meta: model = DeviceRole - fields = ('name', 'slug', 'color', 'vm_role', 'description') + fields = ('name', 'slug', 'color', 'vm_role', 'description', 'tags') help_texts = { 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), } @@ -289,7 +289,7 @@ class PlatformCSVForm(NetBoxModelCSVForm): class Meta: model = Platform - fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description') + fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags') class BaseDeviceCSVForm(NetBoxModelCSVForm): @@ -388,7 +388,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority', - 'cluster', 'description', 'comments', + 'cluster', 'description', 'comments', 'tags', ] def __init__(self, data=None, *args, **kwargs): @@ -425,7 +425,7 @@ class ModuleCSVForm(NetBoxModelCSVForm): class Meta: model = Module fields = ( - 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -452,7 +452,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', + 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', 'tags' ] def __init__(self, data=None, *args, **kwargs): @@ -503,7 +503,7 @@ class ConsolePortCSVForm(NetBoxModelCSVForm): class Meta: model = ConsolePort - fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') + fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags') class ConsoleServerPortCSVForm(NetBoxModelCSVForm): @@ -526,7 +526,7 @@ class ConsoleServerPortCSVForm(NetBoxModelCSVForm): class Meta: model = ConsoleServerPort - fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') + fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags') class PowerPortCSVForm(NetBoxModelCSVForm): @@ -543,7 +543,7 @@ class PowerPortCSVForm(NetBoxModelCSVForm): class Meta: model = PowerPort fields = ( - 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', + 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', 'tags' ) @@ -571,7 +571,7 @@ class PowerOutletCSVForm(NetBoxModelCSVForm): class Meta: model = PowerOutlet - fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description') + fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description', 'tags') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -659,7 +659,7 @@ class InterfaceCSVForm(NetBoxModelCSVForm): fields = ( 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', - 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags' ) def __init__(self, data=None, *args, **kwargs): @@ -702,7 +702,7 @@ class FrontPortCSVForm(NetBoxModelCSVForm): model = FrontPort fields = ( 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position', - 'description', + 'description', 'tags' ) help_texts = { 'rear_port_position': 'Mapped position on corresponding rear port', @@ -743,7 +743,7 @@ class RearPortCSVForm(NetBoxModelCSVForm): class Meta: model = RearPort - fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description') + fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'tags') help_texts = { 'positions': 'Number of front ports which may be mapped' } @@ -757,7 +757,7 @@ class ModuleBayCSVForm(NetBoxModelCSVForm): class Meta: model = ModuleBay - fields = ('device', 'name', 'label', 'position', 'description') + fields = ('device', 'name', 'label', 'position', 'description', 'tags') class DeviceBayCSVForm(NetBoxModelCSVForm): @@ -777,7 +777,7 @@ class DeviceBayCSVForm(NetBoxModelCSVForm): class Meta: model = DeviceBay - fields = ('device', 'name', 'label', 'installed_device', 'description') + fields = ('device', 'name', 'label', 'installed_device', 'description', 'tags') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -832,7 +832,7 @@ class InventoryItemCSVForm(NetBoxModelCSVForm): model = InventoryItem fields = ( 'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', - 'description', + 'description', 'tags' ) def __init__(self, *args, **kwargs): @@ -928,7 +928,7 @@ class CableCSVForm(NetBoxModelCSVForm): model = Cable fields = [ 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', - 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', + 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', ] help_texts = { 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), @@ -985,7 +985,7 @@ class VirtualChassisCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualChassis - fields = ('name', 'domain', 'master', 'description') + fields = ('name', 'domain', 'master', 'description', 'comments', 'tags') # @@ -1006,7 +1006,7 @@ class PowerPanelCSVForm(NetBoxModelCSVForm): class Meta: model = PowerPanel - fields = ('site', 'location', 'name', 'description', 'comments') + fields = ('site', 'location', 'name', 'description', 'comments', 'tags') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -1062,7 +1062,7 @@ class PowerFeedCSVForm(NetBoxModelCSVForm): model = PowerFeed fields = ( 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', - 'voltage', 'amperage', 'max_utilization', 'description', 'comments', + 'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 3a31b6757..4cd0bb69f 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -41,7 +41,7 @@ class VRFCSVForm(NetBoxModelCSVForm): class Meta: model = VRF - fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments') + fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags') class RouteTargetCSVForm(NetBoxModelCSVForm): @@ -54,7 +54,7 @@ class RouteTargetCSVForm(NetBoxModelCSVForm): class Meta: model = RouteTarget - fields = ('name', 'tenant', 'description', 'comments') + fields = ('name', 'tenant', 'description', 'comments', 'tags') class RIRCSVForm(NetBoxModelCSVForm): @@ -62,7 +62,7 @@ class RIRCSVForm(NetBoxModelCSVForm): class Meta: model = RIR - fields = ('name', 'slug', 'is_private', 'description') + fields = ('name', 'slug', 'is_private', 'description', 'tags') help_texts = { 'name': 'RIR name', } @@ -83,7 +83,7 @@ class AggregateCSVForm(NetBoxModelCSVForm): class Meta: model = Aggregate - fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments') + fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'tags') class ASNCSVForm(NetBoxModelCSVForm): @@ -101,8 +101,7 @@ class ASNCSVForm(NetBoxModelCSVForm): class Meta: model = ASN - fields = ('asn', 'rir', 'tenant', 'description', 'comments') - help_texts = {} + fields = ('asn', 'rir', 'tenant', 'description', 'comments', 'tags') class RoleCSVForm(NetBoxModelCSVForm): @@ -110,7 +109,7 @@ class RoleCSVForm(NetBoxModelCSVForm): class Meta: model = Role - fields = ('name', 'slug', 'weight', 'description') + fields = ('name', 'slug', 'weight', 'description', 'tags') class PrefixCSVForm(NetBoxModelCSVForm): @@ -159,7 +158,7 @@ class PrefixCSVForm(NetBoxModelCSVForm): model = Prefix fields = ( 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', - 'description', 'comments', + 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -204,7 +203,7 @@ class IPRangeCSVForm(NetBoxModelCSVForm): class Meta: model = IPRange fields = ( - 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', 'comments', + 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', 'comments', 'tags', ) @@ -257,7 +256,7 @@ class IPAddressCSVForm(NetBoxModelCSVForm): model = IPAddress fields = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', - 'dns_name', 'description', 'comments', + 'dns_name', 'description', 'comments', 'tags', ] def __init__(self, data=None, *args, **kwargs): @@ -326,7 +325,7 @@ class FHRPGroupCSVForm(NetBoxModelCSVForm): class Meta: model = FHRPGroup - fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'comments') + fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'comments', 'tags') class VLANGroupCSVForm(NetBoxModelCSVForm): @@ -351,7 +350,7 @@ class VLANGroupCSVForm(NetBoxModelCSVForm): class Meta: model = VLANGroup - fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description') + fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description', 'tags') labels = { 'scope_id': 'Scope ID', } @@ -389,7 +388,7 @@ class VLANCSVForm(NetBoxModelCSVForm): class Meta: model = VLAN - fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments') + fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags') help_texts = { 'vid': 'Numeric VLAN ID (1-4094)', 'name': 'VLAN name', @@ -404,7 +403,7 @@ class ServiceTemplateCSVForm(NetBoxModelCSVForm): class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description', 'comments') + fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags') class ServiceCSVForm(NetBoxModelCSVForm): @@ -427,7 +426,7 @@ class ServiceCSVForm(NetBoxModelCSVForm): class Meta: model = Service - fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments') + fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags') class L2VPNCSVForm(NetBoxModelCSVForm): @@ -443,7 +442,7 @@ class L2VPNCSVForm(NetBoxModelCSVForm): class Meta: model = L2VPN - fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments') + fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags') class L2VPNTerminationCSVForm(NetBoxModelCSVForm): @@ -480,7 +479,7 @@ class L2VPNTerminationCSVForm(NetBoxModelCSVForm): class Meta: model = L2VPNTermination - fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan') + fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'tags') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 564e254a3..4a4368a65 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -1,12 +1,13 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db.models import Q from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin from extras.models import CustomField, Tag from utilities.forms import BootstrapMixin, CSVModelForm -from utilities.forms.fields import DynamicModelMultipleChoiceField +from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField __all__ = ( 'NetBoxModelForm', @@ -61,7 +62,12 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): """ Base form for creating a NetBox objects from CSV data. Used for bulk importing. """ - tags = None # Temporary fix in lieu of tag import support (see #9158) + tags = CSVModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False, + to_field_name='slug', + help_text='Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")' + ) def _get_custom_fields(self, content_type): return CustomField.objects.filter(content_types=content_type).filter( diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py new file mode 100644 index 000000000..73f2e0e27 --- /dev/null +++ b/netbox/netbox/tests/test_import.py @@ -0,0 +1,84 @@ +from django.contrib.contenttypes.models import ContentType +from django.test import override_settings + +from dcim.models import * +from users.models import ObjectPermission +from utilities.testing import ModelViewTestCase, create_tags + + +class CSVImportTestCase(ModelViewTestCase): + model = Region + + @classmethod + def setUpTestData(cls): + create_tags('Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo') + + def _get_csv_data(self, csv_data): + return '\n'.join(csv_data) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_valid_tags(self): + csv_data = ( + 'name,slug,tags', + 'Region 1,region-1,"alpha,bravo"', + 'Region 2,region-2,"charlie,delta"', + 'Region 3,region-3,echo', + 'Region 4,region-4,', + ) + + data = { + 'csv': self._get_csv_data(csv_data), + } + + # Assign model-level permission + obj_perm = ObjectPermission(name='Test permission', actions=['add']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('import')), 200) + + # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + regions = Region.objects.all() + self.assertEqual(regions.count(), 4) + region = Region.objects.get(slug="region-4") + self.assertEqual( + list(regions[0].tags.values_list('name', flat=True)), + ['Alpha', 'Bravo'] + ) + self.assertEqual( + list(regions[1].tags.values_list('name', flat=True)), + ['Charlie', 'Delta'] + ) + self.assertEqual( + list(regions[2].tags.values_list('name', flat=True)), + ['Echo'] + ) + self.assertEqual(regions[3].tags.count(), 0) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_invalid_tags(self): + csv_data = ( + 'name,slug,tags', + 'Region 1,region-1,"Alpha,Bravo"', # Valid + 'Region 2,region-2,"Alpha,Tango"', # Invalid + ) + + data = { + 'csv': self._get_csv_data(csv_data), + } + + # Assign model-level permission + obj_perm = ObjectPermission(name='Test permission', actions=['add']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('import')), 200) + + # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + self.assertEqual(Region.objects.count(), 0) diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index a465230c5..137f79d42 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -26,7 +26,7 @@ class TenantGroupCSVForm(NetBoxModelCSVForm): class Meta: model = TenantGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class TenantCSVForm(NetBoxModelCSVForm): @@ -40,7 +40,7 @@ class TenantCSVForm(NetBoxModelCSVForm): class Meta: model = Tenant - fields = ('name', 'slug', 'group', 'description', 'comments') + fields = ('name', 'slug', 'group', 'description', 'comments', 'tags') # @@ -58,7 +58,7 @@ class ContactGroupCSVForm(NetBoxModelCSVForm): class Meta: model = ContactGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class ContactRoleCSVForm(NetBoxModelCSVForm): @@ -79,4 +79,4 @@ class ContactCSVForm(NetBoxModelCSVForm): class Meta: model = Contact - fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments') + fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags') diff --git a/netbox/utilities/forms/fields/csv.py b/netbox/utilities/forms/fields/csv.py index 275c8084c..59765cae8 100644 --- a/netbox/utilities/forms/fields/csv.py +++ b/netbox/utilities/forms/fields/csv.py @@ -16,6 +16,7 @@ __all__ = ( 'CSVDataField', 'CSVFileField', 'CSVModelChoiceField', + 'CSVModelMultipleChoiceField', 'CSVMultipleChoiceField', 'CSVMultipleContentTypeField', 'CSVTypedChoiceField', @@ -142,7 +143,7 @@ class CSVModelChoiceField(forms.ModelChoiceField): Extends Django's `ModelChoiceField` to provide additional validation for CSV values. """ default_error_messages = { - 'invalid_choice': 'Object not found.', + 'invalid_choice': 'Object not found: %(value)s', } def to_python(self, value): @@ -154,6 +155,19 @@ class CSVModelChoiceField(forms.ModelChoiceField): ) +class CSVModelMultipleChoiceField(forms.ModelMultipleChoiceField): + """ + Extends Django's `ModelMultipleChoiceField` to support comma-separated values. + """ + default_error_messages = { + 'invalid_choice': 'Object not found: %(value)s', + } + + def clean(self, value): + value = value.split(',') if value else [] + return super().clean(value) + + class CSVContentTypeField(CSVModelChoiceField): """ CSV field for referencing a single content type, in the form `.`. diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index d140197dd..6fc704ae4 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -21,7 +21,7 @@ class ClusterTypeCSVForm(NetBoxModelCSVForm): class Meta: model = ClusterType - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') class ClusterGroupCSVForm(NetBoxModelCSVForm): @@ -29,7 +29,7 @@ class ClusterGroupCSVForm(NetBoxModelCSVForm): class Meta: model = ClusterGroup - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') class ClusterCSVForm(NetBoxModelCSVForm): @@ -63,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments') + fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags') class VirtualMachineCSVForm(NetBoxModelCSVForm): @@ -114,7 +114,7 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): model = VirtualMachine fields = ( 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'description', 'comments', + 'description', 'comments', 'tags', ) @@ -151,7 +151,7 @@ class VMInterfaceCSVForm(NetBoxModelCSVForm): model = VMInterface fields = ( 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'vrf', + 'vrf', 'tags' ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 03ac997a3..00078c8eb 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -25,7 +25,7 @@ class WirelessLANGroupCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLANGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class WirelessLANCSVForm(NetBoxModelCSVForm): @@ -62,6 +62,7 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): model = WirelessLAN fields = ( 'ssid', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', + 'tags', ) @@ -97,5 +98,5 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm): model = WirelessLink fields = ( 'interface_a', 'interface_b', 'ssid', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', - 'comments', + 'comments', 'tags', )