Просмотр исходного кода

7376 csv tags (#10802)

* 7376 add tags to CSV import

* 7376 change help text

* 7376 validate tags

* 7376 fix tests

* 7376 add tag validation tests

* Introduce CSVModelMultipleChoiceField for CSV import tag assignment

* Clean up CSVImportTestCase

Co-authored-by: jeremystretch <jstretch@ns1.com>
Arthur Hanson 3 лет назад
Родитель
Сommit
cdeb65e2fb

+ 4 - 4
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'
         ]

+ 26 - 26
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. <code>00ff00</code>)'),
         }
@@ -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. <code>00ff00</code>)'),
         }
@@ -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. <code>00ff00</code>)'),
@@ -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):

+ 16 - 17
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)

+ 8 - 2
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(

+ 84 - 0
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)

+ 4 - 4
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')

+ 15 - 1
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 `<app>.<model>`.

+ 5 - 5
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):

+ 3 - 2
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',
         )