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

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:
     class Meta:
         model = Provider
         model = Provider
         fields = (
         fields = (
-            'name', 'slug', 'account', 'description', 'comments',
+            'name', 'slug', 'account', 'description', 'comments', 'tags',
         )
         )
 
 
 
 
@@ -32,7 +32,7 @@ class ProviderNetworkCSVForm(NetBoxModelCSVForm):
     class Meta:
     class Meta:
         model = ProviderNetwork
         model = ProviderNetwork
         fields = [
         fields = [
-            'provider', 'name', 'service_id', 'description', 'comments',
+            'provider', 'name', 'service_id', 'description', 'comments', 'tags'
         ]
         ]
 
 
 
 
@@ -41,7 +41,7 @@ class CircuitTypeCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
-        fields = ('name', 'slug', 'description')
+        fields = ('name', 'slug', 'description', 'tags')
         help_texts = {
         help_texts = {
             'name': 'Name of circuit type',
             'name': 'Name of circuit type',
         }
         }
@@ -73,5 +73,5 @@ class CircuitCSVForm(NetBoxModelCSVForm):
         model = Circuit
         model = Circuit
         fields = [
         fields = [
             'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
             '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:
     class Meta:
         model = Region
         model = Region
-        fields = ('name', 'slug', 'parent', 'description')
+        fields = ('name', 'slug', 'parent', 'description', 'tags')
 
 
 
 
 class SiteGroupCSVForm(NetBoxModelCSVForm):
 class SiteGroupCSVForm(NetBoxModelCSVForm):
@@ -100,7 +100,7 @@ class SiteCSVForm(NetBoxModelCSVForm):
         model = Site
         model = Site
         fields = (
         fields = (
             'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description',
             '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 = {
         help_texts = {
             'time_zone': mark_safe(
             'time_zone': mark_safe(
@@ -137,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = Location
         model = Location
-        fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description')
+        fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
 
 
 
 
 class RackRoleCSVForm(NetBoxModelCSVForm):
 class RackRoleCSVForm(NetBoxModelCSVForm):
@@ -145,7 +145,7 @@ class RackRoleCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
-        fields = ('name', 'slug', 'color', 'description')
+        fields = ('name', 'slug', 'color', 'description', 'tags')
         help_texts = {
         help_texts = {
             'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
             'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
         }
         }
@@ -197,7 +197,7 @@ class RackCSVForm(NetBoxModelCSVForm):
         fields = (
         fields = (
             'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
             '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',
             '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):
     def __init__(self, data=None, *args, **kwargs):
@@ -241,7 +241,7 @@ class RackReservationCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = RackReservation
         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):
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
         super().__init__(data, *args, **kwargs)
@@ -264,7 +264,7 @@ class ManufacturerCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
-        fields = ('name', 'slug', 'description')
+        fields = ('name', 'slug', 'description', 'tags')
 
 
 
 
 class DeviceRoleCSVForm(NetBoxModelCSVForm):
 class DeviceRoleCSVForm(NetBoxModelCSVForm):
@@ -272,7 +272,7 @@ class DeviceRoleCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
-        fields = ('name', 'slug', 'color', 'vm_role', 'description')
+        fields = ('name', 'slug', 'color', 'vm_role', 'description', 'tags')
         help_texts = {
         help_texts = {
             'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
             'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
         }
         }
@@ -289,7 +289,7 @@ class PlatformCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
-        fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description')
+        fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags')
 
 
 
 
 class BaseDeviceCSVForm(NetBoxModelCSVForm):
 class BaseDeviceCSVForm(NetBoxModelCSVForm):
@@ -388,7 +388,7 @@ class DeviceCSVForm(BaseDeviceCSVForm):
         fields = [
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority',
             '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):
     def __init__(self, data=None, *args, **kwargs):
@@ -425,7 +425,7 @@ class ModuleCSVForm(NetBoxModelCSVForm):
     class Meta:
     class Meta:
         model = Module
         model = Module
         fields = (
         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):
     def __init__(self, data=None, *args, **kwargs):
@@ -452,7 +452,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
     class Meta(BaseDeviceCSVForm.Meta):
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             '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):
     def __init__(self, data=None, *args, **kwargs):
@@ -503,7 +503,7 @@ class ConsolePortCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
-        fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
+        fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags')
 
 
 
 
 class ConsoleServerPortCSVForm(NetBoxModelCSVForm):
 class ConsoleServerPortCSVForm(NetBoxModelCSVForm):
@@ -526,7 +526,7 @@ class ConsoleServerPortCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
-        fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
+        fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags')
 
 
 
 
 class PowerPortCSVForm(NetBoxModelCSVForm):
 class PowerPortCSVForm(NetBoxModelCSVForm):
@@ -543,7 +543,7 @@ class PowerPortCSVForm(NetBoxModelCSVForm):
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
         fields = (
         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:
     class Meta:
         model = PowerOutlet
         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):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -659,7 +659,7 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
         fields = (
         fields = (
             'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
             'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
             'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
             '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):
     def __init__(self, data=None, *args, **kwargs):
@@ -702,7 +702,7 @@ class FrontPortCSVForm(NetBoxModelCSVForm):
         model = FrontPort
         model = FrontPort
         fields = (
         fields = (
             'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position',
             'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position',
-            'description',
+            'description', 'tags'
         )
         )
         help_texts = {
         help_texts = {
             'rear_port_position': 'Mapped position on corresponding rear port',
             'rear_port_position': 'Mapped position on corresponding rear port',
@@ -743,7 +743,7 @@ class RearPortCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = RearPort
         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 = {
         help_texts = {
             'positions': 'Number of front ports which may be mapped'
             'positions': 'Number of front ports which may be mapped'
         }
         }
@@ -757,7 +757,7 @@ class ModuleBayCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = ModuleBay
         model = ModuleBay
-        fields = ('device', 'name', 'label', 'position', 'description')
+        fields = ('device', 'name', 'label', 'position', 'description', 'tags')
 
 
 
 
 class DeviceBayCSVForm(NetBoxModelCSVForm):
 class DeviceBayCSVForm(NetBoxModelCSVForm):
@@ -777,7 +777,7 @@ class DeviceBayCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
-        fields = ('device', 'name', 'label', 'installed_device', 'description')
+        fields = ('device', 'name', 'label', 'installed_device', 'description', 'tags')
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -832,7 +832,7 @@ class InventoryItemCSVForm(NetBoxModelCSVForm):
         model = InventoryItem
         model = InventoryItem
         fields = (
         fields = (
             'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
             'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
-            'description',
+            'description', 'tags'
         )
         )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -928,7 +928,7 @@ class CableCSVForm(NetBoxModelCSVForm):
         model = Cable
         model = Cable
         fields = [
         fields = [
             'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
             '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 = {
         help_texts = {
             'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
             'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
@@ -985,7 +985,7 @@ class VirtualChassisCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = VirtualChassis
         model = VirtualChassis
-        fields = ('name', 'domain', 'master', 'description')
+        fields = ('name', 'domain', 'master', 'description', 'comments', 'tags')
 
 
 
 
 #
 #
@@ -1006,7 +1006,7 @@ class PowerPanelCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = PowerPanel
         model = PowerPanel
-        fields = ('site', 'location', 'name', 'description', 'comments')
+        fields = ('site', 'location', 'name', 'description', 'comments', 'tags')
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
         super().__init__(data, *args, **kwargs)
@@ -1062,7 +1062,7 @@ class PowerFeedCSVForm(NetBoxModelCSVForm):
         model = PowerFeed
         model = PowerFeed
         fields = (
         fields = (
             'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
             '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):
     def __init__(self, data=None, *args, **kwargs):

+ 16 - 17
netbox/ipam/forms/bulk_import.py

@@ -41,7 +41,7 @@ class VRFCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments')
+        fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags')
 
 
 
 
 class RouteTargetCSVForm(NetBoxModelCSVForm):
 class RouteTargetCSVForm(NetBoxModelCSVForm):
@@ -54,7 +54,7 @@ class RouteTargetCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = RouteTarget
         model = RouteTarget
-        fields = ('name', 'tenant', 'description', 'comments')
+        fields = ('name', 'tenant', 'description', 'comments', 'tags')
 
 
 
 
 class RIRCSVForm(NetBoxModelCSVForm):
 class RIRCSVForm(NetBoxModelCSVForm):
@@ -62,7 +62,7 @@ class RIRCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
-        fields = ('name', 'slug', 'is_private', 'description')
+        fields = ('name', 'slug', 'is_private', 'description', 'tags')
         help_texts = {
         help_texts = {
             'name': 'RIR name',
             'name': 'RIR name',
         }
         }
@@ -83,7 +83,7 @@ class AggregateCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = Aggregate
         model = Aggregate
-        fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments')
+        fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'tags')
 
 
 
 
 class ASNCSVForm(NetBoxModelCSVForm):
 class ASNCSVForm(NetBoxModelCSVForm):
@@ -101,8 +101,7 @@ class ASNCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = ASN
         model = ASN
-        fields = ('asn', 'rir', 'tenant', 'description', 'comments')
-        help_texts = {}
+        fields = ('asn', 'rir', 'tenant', 'description', 'comments', 'tags')
 
 
 
 
 class RoleCSVForm(NetBoxModelCSVForm):
 class RoleCSVForm(NetBoxModelCSVForm):
@@ -110,7 +109,7 @@ class RoleCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = Role
         model = Role
-        fields = ('name', 'slug', 'weight', 'description')
+        fields = ('name', 'slug', 'weight', 'description', 'tags')
 
 
 
 
 class PrefixCSVForm(NetBoxModelCSVForm):
 class PrefixCSVForm(NetBoxModelCSVForm):
@@ -159,7 +158,7 @@ class PrefixCSVForm(NetBoxModelCSVForm):
         model = Prefix
         model = Prefix
         fields = (
         fields = (
             'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized',
             '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):
     def __init__(self, data=None, *args, **kwargs):
@@ -204,7 +203,7 @@ class IPRangeCSVForm(NetBoxModelCSVForm):
     class Meta:
     class Meta:
         model = IPRange
         model = IPRange
         fields = (
         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
         model = IPAddress
         fields = [
         fields = [
             'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
             '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):
     def __init__(self, data=None, *args, **kwargs):
@@ -326,7 +325,7 @@ class FHRPGroupCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = FHRPGroup
         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):
 class VLANGroupCSVForm(NetBoxModelCSVForm):
@@ -351,7 +350,7 @@ class VLANGroupCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         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 = {
         labels = {
             'scope_id': 'Scope ID',
             'scope_id': 'Scope ID',
         }
         }
@@ -389,7 +388,7 @@ class VLANCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = VLAN
         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 = {
         help_texts = {
             'vid': 'Numeric VLAN ID (1-4094)',
             'vid': 'Numeric VLAN ID (1-4094)',
             'name': 'VLAN name',
             'name': 'VLAN name',
@@ -404,7 +403,7 @@ class ServiceTemplateCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = ServiceTemplate
         model = ServiceTemplate
-        fields = ('name', 'protocol', 'ports', 'description', 'comments')
+        fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags')
 
 
 
 
 class ServiceCSVForm(NetBoxModelCSVForm):
 class ServiceCSVForm(NetBoxModelCSVForm):
@@ -427,7 +426,7 @@ class ServiceCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = Service
         model = Service
-        fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments')
+        fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags')
 
 
 
 
 class L2VPNCSVForm(NetBoxModelCSVForm):
 class L2VPNCSVForm(NetBoxModelCSVForm):
@@ -443,7 +442,7 @@ class L2VPNCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = L2VPN
         model = L2VPN
-        fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments')
+        fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags')
 
 
 
 
 class L2VPNTerminationCSVForm(NetBoxModelCSVForm):
 class L2VPNTerminationCSVForm(NetBoxModelCSVForm):
@@ -480,7 +479,7 @@ class L2VPNTerminationCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = L2VPNTermination
         model = L2VPNTermination
-        fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan')
+        fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'tags')
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
         super().__init__(data, *args, **kwargs)

+ 8 - 2
netbox/netbox/forms/base.py

@@ -1,12 +1,13 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.db.models import Q
 from django.db.models import Q
 
 
 from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
 from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
 from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin
 from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin
 from extras.models import CustomField, Tag
 from extras.models import CustomField, Tag
 from utilities.forms import BootstrapMixin, CSVModelForm
 from utilities.forms import BootstrapMixin, CSVModelForm
-from utilities.forms.fields import DynamicModelMultipleChoiceField
+from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
 
 
 __all__ = (
 __all__ = (
     'NetBoxModelForm',
     'NetBoxModelForm',
@@ -61,7 +62,12 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm):
     """
     """
     Base form for creating a NetBox objects from CSV data. Used for bulk importing.
     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):
     def _get_custom_fields(self, content_type):
         return CustomField.objects.filter(content_types=content_type).filter(
         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:
     class Meta:
         model = TenantGroup
         model = TenantGroup
-        fields = ('name', 'slug', 'parent', 'description')
+        fields = ('name', 'slug', 'parent', 'description', 'tags')
 
 
 
 
 class TenantCSVForm(NetBoxModelCSVForm):
 class TenantCSVForm(NetBoxModelCSVForm):
@@ -40,7 +40,7 @@ class TenantCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
-        fields = ('name', 'slug', 'group', 'description', 'comments')
+        fields = ('name', 'slug', 'group', 'description', 'comments', 'tags')
 
 
 
 
 #
 #
@@ -58,7 +58,7 @@ class ContactGroupCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = ContactGroup
         model = ContactGroup
-        fields = ('name', 'slug', 'parent', 'description')
+        fields = ('name', 'slug', 'parent', 'description', 'tags')
 
 
 
 
 class ContactRoleCSVForm(NetBoxModelCSVForm):
 class ContactRoleCSVForm(NetBoxModelCSVForm):
@@ -79,4 +79,4 @@ class ContactCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = Contact
         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',
     'CSVDataField',
     'CSVFileField',
     'CSVFileField',
     'CSVModelChoiceField',
     'CSVModelChoiceField',
+    'CSVModelMultipleChoiceField',
     'CSVMultipleChoiceField',
     'CSVMultipleChoiceField',
     'CSVMultipleContentTypeField',
     'CSVMultipleContentTypeField',
     'CSVTypedChoiceField',
     'CSVTypedChoiceField',
@@ -142,7 +143,7 @@ class CSVModelChoiceField(forms.ModelChoiceField):
     Extends Django's `ModelChoiceField` to provide additional validation for CSV values.
     Extends Django's `ModelChoiceField` to provide additional validation for CSV values.
     """
     """
     default_error_messages = {
     default_error_messages = {
-        'invalid_choice': 'Object not found.',
+        'invalid_choice': 'Object not found: %(value)s',
     }
     }
 
 
     def to_python(self, value):
     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):
 class CSVContentTypeField(CSVModelChoiceField):
     """
     """
     CSV field for referencing a single content type, in the form `<app>.<model>`.
     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:
     class Meta:
         model = ClusterType
         model = ClusterType
-        fields = ('name', 'slug', 'description')
+        fields = ('name', 'slug', 'description', 'tags')
 
 
 
 
 class ClusterGroupCSVForm(NetBoxModelCSVForm):
 class ClusterGroupCSVForm(NetBoxModelCSVForm):
@@ -29,7 +29,7 @@ class ClusterGroupCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup
-        fields = ('name', 'slug', 'description')
+        fields = ('name', 'slug', 'description', 'tags')
 
 
 
 
 class ClusterCSVForm(NetBoxModelCSVForm):
 class ClusterCSVForm(NetBoxModelCSVForm):
@@ -63,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments')
+        fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags')
 
 
 
 
 class VirtualMachineCSVForm(NetBoxModelCSVForm):
 class VirtualMachineCSVForm(NetBoxModelCSVForm):
@@ -114,7 +114,7 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         fields = (
             'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
             '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
         model = VMInterface
         fields = (
         fields = (
             'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
             'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
-            'vrf',
+            'vrf', 'tags'
         )
         )
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):

+ 3 - 2
netbox/wireless/forms/bulk_import.py

@@ -25,7 +25,7 @@ class WirelessLANGroupCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = WirelessLANGroup
         model = WirelessLANGroup
-        fields = ('name', 'slug', 'parent', 'description')
+        fields = ('name', 'slug', 'parent', 'description', 'tags')
 
 
 
 
 class WirelessLANCSVForm(NetBoxModelCSVForm):
 class WirelessLANCSVForm(NetBoxModelCSVForm):
@@ -62,6 +62,7 @@ class WirelessLANCSVForm(NetBoxModelCSVForm):
         model = WirelessLAN
         model = WirelessLAN
         fields = (
         fields = (
             'ssid', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments',
             'ssid', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments',
+            'tags',
         )
         )
 
 
 
 
@@ -97,5 +98,5 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm):
         model = WirelessLink
         model = WirelessLink
         fields = (
         fields = (
             'interface_a', 'interface_b', 'ssid', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description',
             'interface_a', 'interface_b', 'ssid', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description',
-            'comments',
+            'comments', 'tags',
         )
         )