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

Merge pull request #3621 from netbox-community/451-devicetype-import

Enable YAML/JSON-based DeviceType import
Jeremy Stretch 6 лет назад
Родитель
Сommit
c6893731ad

+ 400 - 21
netbox/dcim/constants.py

@@ -1,4 +1,3 @@
-
 # Rack types
 RACK_TYPE_2POST = 100
 RACK_TYPE_4POST = 200
@@ -58,7 +57,10 @@ SUBDEVICE_ROLE_CHOICES = (
     (SUBDEVICE_ROLE_CHILD, 'Child'),
 )
 
-# Interface types
+#
+# Numeric interface types
+#
+
 # Virtual
 IFACE_TYPE_VIRTUAL = 0
 IFACE_TYPE_LAG = 200
@@ -113,15 +115,15 @@ IFACE_TYPE_16GFC_SFP_PLUS = 3160
 IFACE_TYPE_32GFC_SFP28 = 3320
 IFACE_TYPE_128GFC_QSFP28 = 3400
 # InfiniBand
-IFACE_FF_INFINIBAND_SDR = 7010
-IFACE_FF_INFINIBAND_DDR = 7020
-IFACE_FF_INFINIBAND_QDR = 7030
-IFACE_FF_INFINIBAND_FDR10 = 7040
-IFACE_FF_INFINIBAND_FDR = 7050
-IFACE_FF_INFINIBAND_EDR = 7060
-IFACE_FF_INFINIBAND_HDR = 7070
-IFACE_FF_INFINIBAND_NDR = 7080
-IFACE_FF_INFINIBAND_XDR = 7090
+IFACE_TYPE_INFINIBAND_SDR = 7010
+IFACE_TYPE_INFINIBAND_DDR = 7020
+IFACE_TYPE_INFINIBAND_QDR = 7030
+IFACE_TYPE_INFINIBAND_FDR10 = 7040
+IFACE_TYPE_INFINIBAND_FDR = 7050
+IFACE_TYPE_INFINIBAND_EDR = 7060
+IFACE_TYPE_INFINIBAND_HDR = 7070
+IFACE_TYPE_INFINIBAND_NDR = 7080
+IFACE_TYPE_INFINIBAND_XDR = 7090
 # Serial
 IFACE_TYPE_T1 = 4000
 IFACE_TYPE_E1 = 4010
@@ -227,15 +229,15 @@ IFACE_TYPE_CHOICES = [
     [
         'InfiniBand',
         [
-            [IFACE_FF_INFINIBAND_SDR, 'SDR (2 Gbps)'],
-            [IFACE_FF_INFINIBAND_DDR, 'DDR (4 Gbps)'],
-            [IFACE_FF_INFINIBAND_QDR, 'QDR (8 Gbps)'],
-            [IFACE_FF_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'],
-            [IFACE_FF_INFINIBAND_FDR, 'FDR (13.5 Gbps)'],
-            [IFACE_FF_INFINIBAND_EDR, 'EDR (25 Gbps)'],
-            [IFACE_FF_INFINIBAND_HDR, 'HDR (50 Gbps)'],
-            [IFACE_FF_INFINIBAND_NDR, 'NDR (100 Gbps)'],
-            [IFACE_FF_INFINIBAND_XDR, 'XDR (250 Gbps)'],
+            [IFACE_TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'],
+            [IFACE_TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'],
+            [IFACE_TYPE_INFINIBAND_QDR, 'QDR (8 Gbps)'],
+            [IFACE_TYPE_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'],
+            [IFACE_TYPE_INFINIBAND_FDR, 'FDR (13.5 Gbps)'],
+            [IFACE_TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'],
+            [IFACE_TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'],
+            [IFACE_TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'],
+            [IFACE_TYPE_INFINIBAND_XDR, 'XDR (250 Gbps)'],
         ]
     ],
     [
@@ -382,7 +384,8 @@ CONNECTION_STATUS_CHOICES = [
 
 # Cable endpoint types
 CABLE_TERMINATION_TYPES = [
-    'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination',
+    'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport',
+    'circuittermination',
 ]
 
 # Cable types
@@ -510,3 +513,379 @@ POWERFEED_LEG_CHOICES = (
     (POWERFEED_LEG_B, 'B'),
     (POWERFEED_LEG_C, 'C'),
 )
+
+
+#
+# Interface type values
+#
+
+class InterfaceTypes:
+    """
+    Interface.type slugs
+    """
+    # Virtual
+    TYPE_VIRTUAL = 'virtual'
+    TYPE_LAG = 'lag'
+
+    # Ethernet
+    TYPE_100ME_FIXED = '100base-tx'
+    TYPE_1GE_FIXED = '1000base-t'
+    TYPE_1GE_GBIC = '1000base-x-gbic'
+    TYPE_1GE_SFP = '1000base-x-sfp'
+    TYPE_2GE_FIXED = '2.5gbase-t'
+    TYPE_5GE_FIXED = '5gbase-t'
+    TYPE_10GE_FIXED = '10gbase-t'
+    TYPE_10GE_CX4 = '10gbase-cx4'
+    TYPE_10GE_SFP_PLUS = '10gbase-x-sfpp'
+    TYPE_10GE_XFP = '10gbase-x-xfp'
+    TYPE_10GE_XENPAK = '10gbase-x-xenpak'
+    TYPE_10GE_X2 = '10gbase-x-x2'
+    TYPE_25GE_SFP28 = '25gbase-x-sfp28'
+    TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp'
+    TYPE_50GE_QSFP28 = '50gbase-x-sfp28'
+    TYPE_100GE_CFP = '100gbase-x-cfp'
+    TYPE_100GE_CFP2 = '100gbase-x-cfp2'
+    TYPE_100GE_CFP4 = '100gbase-x-cfp4'
+    TYPE_100GE_CPAK = '100gbase-x-cpak'
+    TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
+    TYPE_200GE_CFP2 = '200gbase-x-cfp2'
+    TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
+    TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
+
+    # Wireless
+    TYPE_80211A = 'ieee802.11a'
+    TYPE_80211G = 'ieee802.11g'
+    TYPE_80211N = 'ieee802.11n'
+    TYPE_80211AC = 'ieee802.11ac'
+    TYPE_80211AD = 'ieee802.11ad'
+
+    # Cellular
+    TYPE_GSM = 'gsm'
+    TYPE_CDMA = 'cdma'
+    TYPE_LTE = 'lte'
+
+    # SONET
+    TYPE_SONET_OC3 = 'sonet-oc3'
+    TYPE_SONET_OC12 = 'sonet-oc12'
+    TYPE_SONET_OC48 = 'sonet-oc48'
+    TYPE_SONET_OC192 = 'sonet-oc192'
+    TYPE_SONET_OC768 = 'sonet-oc768'
+    TYPE_SONET_OC1920 = 'sonet-oc1920'
+    TYPE_SONET_OC3840 = 'sonet-oc3840'
+
+    # Fibrechannel
+    TYPE_1GFC_SFP = '1gfc-sfp'
+    TYPE_2GFC_SFP = '2gfc-sfp'
+    TYPE_4GFC_SFP = '4gfc-sfp'
+    TYPE_8GFC_SFP_PLUS = '8gfc-sfpp'
+    TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
+    TYPE_32GFC_SFP28 = '32gfc-sfp28'
+    TYPE_128GFC_QSFP28 = '128gfc-sfp28'
+
+    # InfiniBand
+    TYPE_INFINIBAND_SDR = 'inifiband-sdr'
+    TYPE_INFINIBAND_DDR = 'inifiband-ddr'
+    TYPE_INFINIBAND_QDR = 'inifiband-qdr'
+    TYPE_INFINIBAND_FDR10 = 'inifiband-fdr10'
+    TYPE_INFINIBAND_FDR = 'inifiband-fdr'
+    TYPE_INFINIBAND_EDR = 'inifiband-edr'
+    TYPE_INFINIBAND_HDR = 'inifiband-hdr'
+    TYPE_INFINIBAND_NDR = 'inifiband-ndr'
+    TYPE_INFINIBAND_XDR = 'inifiband-xdr'
+
+    # Serial
+    TYPE_T1 = 't1'
+    TYPE_E1 = 'e1'
+    TYPE_T3 = 't3'
+    TYPE_E3 = 'e3'
+
+    # Stacking
+    TYPE_STACKWISE = 'cisco-stackwise'
+    TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
+    TYPE_FLEXSTACK = 'cisco-flexstack'
+    TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus'
+    TYPE_JUNIPER_VCP = 'juniper-vcp'
+    TYPE_SUMMITSTACK = 'extreme-summitstack'
+    TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
+    TYPE_SUMMITSTACK256 = 'extreme-summitstack-256'
+    TYPE_SUMMITSTACK512 = 'extreme-summitstack-512'
+
+    # Other
+    TYPE_OTHER = 'other'
+
+    TYPE_CHOICES = (
+        (
+            'Virtual interfaces',
+            (
+                (TYPE_VIRTUAL, 'Virtual'),
+                (TYPE_LAG, 'Link Aggregation Group (LAG)'),
+            ),
+        ),
+        (
+            'Ethernet (fixed)',
+            (
+                (TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
+                (TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
+                (TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
+                (TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
+                (TYPE_10GE_FIXED, '10GBASE-T (10GE)'),
+                (TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'),
+            )
+        ),
+        (
+            'Ethernet (modular)',
+            (
+                (TYPE_1GE_GBIC, 'GBIC (1GE)'),
+                (TYPE_1GE_SFP, 'SFP (1GE)'),
+                (TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'),
+                (TYPE_10GE_XFP, 'XFP (10GE)'),
+                (TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
+                (TYPE_10GE_X2, 'X2 (10GE)'),
+                (TYPE_25GE_SFP28, 'SFP28 (25GE)'),
+                (TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'),
+                (TYPE_50GE_QSFP28, 'QSFP28 (50GE)'),
+                (TYPE_100GE_CFP, 'CFP (100GE)'),
+                (TYPE_100GE_CFP2, 'CFP2 (100GE)'),
+                (TYPE_200GE_CFP2, 'CFP2 (200GE)'),
+                (TYPE_100GE_CFP4, 'CFP4 (100GE)'),
+                (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
+                (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
+                (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
+                (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
+            )
+        ),
+        (
+            'Wireless',
+            (
+                (TYPE_80211A, 'IEEE 802.11a'),
+                (TYPE_80211G, 'IEEE 802.11b/g'),
+                (TYPE_80211N, 'IEEE 802.11n'),
+                (TYPE_80211AC, 'IEEE 802.11ac'),
+                (TYPE_80211AD, 'IEEE 802.11ad'),
+            )
+        ),
+        (
+            'Cellular',
+            (
+                (TYPE_GSM, 'GSM'),
+                (TYPE_CDMA, 'CDMA'),
+                (TYPE_LTE, 'LTE'),
+            )
+        ),
+        (
+            'SONET',
+            (
+                (TYPE_SONET_OC3, 'OC-3/STM-1'),
+                (TYPE_SONET_OC12, 'OC-12/STM-4'),
+                (TYPE_SONET_OC48, 'OC-48/STM-16'),
+                (TYPE_SONET_OC192, 'OC-192/STM-64'),
+                (TYPE_SONET_OC768, 'OC-768/STM-256'),
+                (TYPE_SONET_OC1920, 'OC-1920/STM-640'),
+                (TYPE_SONET_OC3840, 'OC-3840/STM-1234'),
+            )
+        ),
+        (
+            'FibreChannel',
+            (
+                (TYPE_1GFC_SFP, 'SFP (1GFC)'),
+                (TYPE_2GFC_SFP, 'SFP (2GFC)'),
+                (TYPE_4GFC_SFP, 'SFP (4GFC)'),
+                (TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'),
+                (TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'),
+                (TYPE_32GFC_SFP28, 'SFP28 (32GFC)'),
+                (TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'),
+            )
+        ),
+        (
+            'InfiniBand',
+            (
+                (TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'),
+                (TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'),
+                (TYPE_INFINIBAND_QDR, 'QDR (8 Gbps)'),
+                (TYPE_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'),
+                (TYPE_INFINIBAND_FDR, 'FDR (13.5 Gbps)'),
+                (TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'),
+                (TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'),
+                (TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'),
+                (TYPE_INFINIBAND_XDR, 'XDR (250 Gbps)'),
+            )
+        ),
+        (
+            'Serial',
+            (
+                (TYPE_T1, 'T1 (1.544 Mbps)'),
+                (TYPE_E1, 'E1 (2.048 Mbps)'),
+                (TYPE_T3, 'T3 (45 Mbps)'),
+                (TYPE_E3, 'E3 (34 Mbps)'),
+            )
+        ),
+        (
+            'Stacking',
+            (
+                (TYPE_STACKWISE, 'Cisco StackWise'),
+                (TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
+                (TYPE_FLEXSTACK, 'Cisco FlexStack'),
+                (TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'),
+                (TYPE_JUNIPER_VCP, 'Juniper VCP'),
+                (TYPE_SUMMITSTACK, 'Extreme SummitStack'),
+                (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
+                (TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'),
+                (TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'),
+            )
+        ),
+        (
+            'Other',
+            (
+                (TYPE_OTHER, 'Other'),
+            )
+        ),
+    )
+
+    @classmethod
+    def slug_to_integer(cls, slug):
+        """
+        Provide backward-compatible mapping of the type slug to integer.
+        """
+        return {
+            # Slug: integer
+            cls.TYPE_VIRTUAL: IFACE_TYPE_VIRTUAL,
+            cls.TYPE_LAG: IFACE_TYPE_LAG,
+            cls.TYPE_100ME_FIXED: IFACE_TYPE_100ME_FIXED,
+            cls.TYPE_1GE_FIXED: IFACE_TYPE_1GE_FIXED,
+            cls.TYPE_1GE_GBIC: IFACE_TYPE_1GE_GBIC,
+            cls.TYPE_1GE_SFP: IFACE_TYPE_1GE_SFP,
+            cls.TYPE_2GE_FIXED: IFACE_TYPE_2GE_FIXED,
+            cls.TYPE_5GE_FIXED: IFACE_TYPE_5GE_FIXED,
+            cls.TYPE_10GE_FIXED: IFACE_TYPE_10GE_FIXED,
+            cls.TYPE_10GE_CX4: IFACE_TYPE_10GE_CX4,
+            cls.TYPE_10GE_SFP_PLUS: IFACE_TYPE_10GE_SFP_PLUS,
+            cls.TYPE_10GE_XFP: IFACE_TYPE_10GE_XFP,
+            cls.TYPE_10GE_XENPAK: IFACE_TYPE_10GE_XENPAK,
+            cls.TYPE_10GE_X2: IFACE_TYPE_10GE_X2,
+            cls.TYPE_25GE_SFP28: IFACE_TYPE_25GE_SFP28,
+            cls.TYPE_40GE_QSFP_PLUS: IFACE_TYPE_40GE_QSFP_PLUS,
+            cls.TYPE_50GE_QSFP28: IFACE_TYPE_50GE_QSFP28,
+            cls.TYPE_100GE_CFP: IFACE_TYPE_100GE_CFP,
+            cls.TYPE_100GE_CFP2: IFACE_TYPE_100GE_CFP2,
+            cls.TYPE_100GE_CFP4: IFACE_TYPE_100GE_CFP4,
+            cls.TYPE_100GE_CPAK: IFACE_TYPE_100GE_CPAK,
+            cls.TYPE_100GE_QSFP28: IFACE_TYPE_100GE_QSFP28,
+            cls.TYPE_200GE_CFP2: IFACE_TYPE_200GE_CFP2,
+            cls.TYPE_200GE_QSFP56: IFACE_TYPE_200GE_QSFP56,
+            cls.TYPE_400GE_QSFP_DD: IFACE_TYPE_400GE_QSFP_DD,
+            cls.TYPE_80211A: IFACE_TYPE_80211A,
+            cls.TYPE_80211G: IFACE_TYPE_80211G,
+            cls.TYPE_80211N: IFACE_TYPE_80211N,
+            cls.TYPE_80211AC: IFACE_TYPE_80211AC,
+            cls.TYPE_80211AD: IFACE_TYPE_80211AD,
+            cls.TYPE_GSM: IFACE_TYPE_GSM,
+            cls.TYPE_CDMA: IFACE_TYPE_CDMA,
+            cls.TYPE_LTE: IFACE_TYPE_LTE,
+            cls.TYPE_SONET_OC3: IFACE_TYPE_SONET_OC3,
+            cls.TYPE_SONET_OC12: IFACE_TYPE_SONET_OC12,
+            cls.TYPE_SONET_OC48: IFACE_TYPE_SONET_OC48,
+            cls.TYPE_SONET_OC192: IFACE_TYPE_SONET_OC192,
+            cls.TYPE_SONET_OC768: IFACE_TYPE_SONET_OC768,
+            cls.TYPE_SONET_OC1920: IFACE_TYPE_SONET_OC1920,
+            cls.TYPE_SONET_OC3840: IFACE_TYPE_SONET_OC3840,
+            cls.TYPE_1GFC_SFP: IFACE_TYPE_1GFC_SFP,
+            cls.TYPE_2GFC_SFP: IFACE_TYPE_2GFC_SFP,
+            cls.TYPE_4GFC_SFP: IFACE_TYPE_4GFC_SFP,
+            cls.TYPE_8GFC_SFP_PLUS: IFACE_TYPE_8GFC_SFP_PLUS,
+            cls.TYPE_16GFC_SFP_PLUS: IFACE_TYPE_16GFC_SFP_PLUS,
+            cls.TYPE_32GFC_SFP28: IFACE_TYPE_32GFC_SFP28,
+            cls.TYPE_128GFC_QSFP28: IFACE_TYPE_128GFC_QSFP28,
+            cls.TYPE_INFINIBAND_SDR: IFACE_TYPE_INFINIBAND_SDR,
+            cls.TYPE_INFINIBAND_DDR: IFACE_TYPE_INFINIBAND_DDR,
+            cls.TYPE_INFINIBAND_QDR: IFACE_TYPE_INFINIBAND_QDR,
+            cls.TYPE_INFINIBAND_FDR10: IFACE_TYPE_INFINIBAND_FDR10,
+            cls.TYPE_INFINIBAND_FDR: IFACE_TYPE_INFINIBAND_FDR,
+            cls.TYPE_INFINIBAND_EDR: IFACE_TYPE_INFINIBAND_EDR,
+            cls.TYPE_INFINIBAND_HDR: IFACE_TYPE_INFINIBAND_HDR,
+            cls.TYPE_INFINIBAND_NDR: IFACE_TYPE_INFINIBAND_NDR,
+            cls.TYPE_INFINIBAND_XDR: IFACE_TYPE_INFINIBAND_XDR,
+            cls.TYPE_T1: IFACE_TYPE_T1,
+            cls.TYPE_E1: IFACE_TYPE_E1,
+            cls.TYPE_T3: IFACE_TYPE_T3,
+            cls.TYPE_E3: IFACE_TYPE_E3,
+            cls.TYPE_STACKWISE: IFACE_TYPE_STACKWISE,
+            cls.TYPE_STACKWISE_PLUS: IFACE_TYPE_STACKWISE_PLUS,
+            cls.TYPE_FLEXSTACK: IFACE_TYPE_FLEXSTACK,
+            cls.TYPE_FLEXSTACK_PLUS: IFACE_TYPE_FLEXSTACK_PLUS,
+            cls.TYPE_JUNIPER_VCP: IFACE_TYPE_JUNIPER_VCP,
+            cls.TYPE_SUMMITSTACK: IFACE_TYPE_SUMMITSTACK,
+            cls.TYPE_SUMMITSTACK128: IFACE_TYPE_SUMMITSTACK128,
+            cls.TYPE_SUMMITSTACK256: IFACE_TYPE_SUMMITSTACK256,
+            cls.TYPE_SUMMITSTACK512: IFACE_TYPE_SUMMITSTACK512,
+        }.get(slug)
+
+
+#
+# Port type values
+#
+
+class PortTypes:
+    """
+    FrontPort/RearPort.type slugs
+    """
+    TYPE_8P8C = '8p8c'
+    TYPE_110_PUNCH = '110-punch'
+    TYPE_BNC = 'bnc'
+    TYPE_ST = 'st'
+    TYPE_SC = 'sc'
+    TYPE_SC_APC = 'sc-apc'
+    TYPE_FC = 'fc'
+    TYPE_LC = 'lc'
+    TYPE_LC_APC = 'lc-apc'
+    TYPE_MTRJ = 'mtrj'
+    TYPE_MPO = 'mpo'
+    TYPE_LSH = 'lsh'
+    TYPE_LSH_APC = 'lsh-apc'
+
+    TYPE_CHOICES = (
+        (
+            'Copper',
+            (
+                (TYPE_8P8C, '8P8C'),
+                (TYPE_110_PUNCH, '110 Punch'),
+                (TYPE_BNC, 'BNC'),
+            ),
+        ),
+        (
+            'Fiber Optic',
+            (
+                (TYPE_FC, 'FC'),
+                (TYPE_LC, 'LC'),
+                (TYPE_LC_APC, 'LC/APC'),
+                (TYPE_LSH, 'LSH'),
+                (TYPE_LSH_APC, 'LSH/APC'),
+                (TYPE_MPO, 'MPO'),
+                (TYPE_MTRJ, 'MTRJ'),
+                (TYPE_SC, 'SC'),
+                (TYPE_SC_APC, 'SC/APC'),
+                (TYPE_ST, 'ST'),
+            )
+        )
+    )
+
+    @classmethod
+    def slug_to_integer(cls, slug):
+        """
+        Provide backward-compatible mapping of the type slug to integer.
+        """
+        return {
+            # Slug: integer
+            cls.TYPE_8P8C: PORT_TYPE_8P8C,
+            cls.TYPE_110_PUNCH: PORT_TYPE_8P8C,
+            cls.TYPE_BNC: PORT_TYPE_BNC,
+            cls.TYPE_ST: PORT_TYPE_ST,
+            cls.TYPE_SC: PORT_TYPE_SC,
+            cls.TYPE_SC_APC: PORT_TYPE_SC_APC,
+            cls.TYPE_FC: PORT_TYPE_FC,
+            cls.TYPE_LC: PORT_TYPE_LC,
+            cls.TYPE_LC_APC: PORT_TYPE_LC_APC,
+            cls.TYPE_MTRJ: PORT_TYPE_MTRJ,
+            cls.TYPE_MPO: PORT_TYPE_MPO,
+            cls.TYPE_LSH: PORT_TYPE_LSH,
+            cls.TYPE_LSH_APC: PORT_TYPE_LSH_APC,
+        }.get(slug)

+ 139 - 18
netbox/dcim/forms.py

@@ -23,7 +23,7 @@ from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
     BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm,
     ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField,
-    SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
+    SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
 )
 from virtualization.models import Cluster, ClusterGroup
 from .constants import *
@@ -828,29 +828,17 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
         }
 
 
-class DeviceTypeCSVForm(forms.ModelForm):
+class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
     manufacturer = forms.ModelChoiceField(
         queryset=Manufacturer.objects.all(),
-        required=True,
-        to_field_name='name',
-        help_text='Manufacturer name',
-        error_messages={
-            'invalid_choice': 'Manufacturer not found.',
-        }
-    )
-    subdevice_role = CSVChoiceField(
-        choices=SUBDEVICE_ROLE_CHOICES,
-        required=False,
-        help_text='Parent/child status'
+        to_field_name='name'
     )
 
     class Meta:
         model = DeviceType
-        fields = DeviceType.csv_headers
-        help_texts = {
-            'model': 'Model name',
-            'slug': 'URL-friendly slug',
-        }
+        fields = [
+            'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+        ]
 
 
 class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -1232,6 +1220,139 @@ class DeviceBayTemplateCreateForm(ComponentForm):
     )
 
 
+#
+# Component template import forms
+#
+
+class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm):
+
+    def __init__(self, device_type, data=None, *args, **kwargs):
+
+        # Must pass the parent DeviceType on form initialization
+        data.update({
+            'device_type': device_type.pk,
+        })
+
+        super().__init__(data, *args, **kwargs)
+
+    def clean_device_type(self):
+
+        data = self.cleaned_data['device_type']
+
+        # Limit fields referencing other components to the parent DeviceType
+        for field_name, field in self.fields.items():
+            if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type':
+                field.queryset = field.queryset.filter(device_type=data)
+
+        return data
+
+
+class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
+
+    class Meta:
+        model = ConsolePortTemplate
+        fields = [
+            'device_type', 'name',
+        ]
+
+
+class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
+
+    class Meta:
+        model = ConsoleServerPortTemplate
+        fields = [
+            'device_type', 'name',
+        ]
+
+
+class PowerPortTemplateImportForm(ComponentTemplateImportForm):
+
+    class Meta:
+        model = PowerPortTemplate
+        fields = [
+            'device_type', 'name', 'maximum_draw', 'allocated_draw',
+        ]
+
+
+class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPortTemplate.objects.all(),
+        to_field_name='name',
+        required=False
+    )
+
+    class Meta:
+        model = PowerOutletTemplate
+        fields = [
+            'device_type', 'name', 'power_port', 'feed_leg',
+        ]
+
+
+class InterfaceTemplateImportForm(ComponentTemplateImportForm):
+    type = forms.ChoiceField(
+        choices=InterfaceTypes.TYPE_CHOICES
+    )
+
+    class Meta:
+        model = InterfaceTemplate
+        fields = [
+            'device_type', 'name', 'type', 'mgmt_only',
+        ]
+
+    def clean_type(self):
+        # Convert slug value to field integer value
+        slug = self.cleaned_data['type']
+        return InterfaceTypes.slug_to_integer(slug)
+
+
+class FrontPortTemplateImportForm(ComponentTemplateImportForm):
+    type = forms.ChoiceField(
+        choices=PortTypes.TYPE_CHOICES
+    )
+    rear_port = forms.ModelChoiceField(
+        queryset=RearPortTemplate.objects.all(),
+        to_field_name='name',
+        required=False
+    )
+
+    class Meta:
+        model = FrontPortTemplate
+        fields = [
+            'device_type', 'name', 'type', 'rear_port', 'rear_port_position',
+        ]
+
+    def clean_type(self):
+        # Convert slug value to field integer value
+        slug = self.cleaned_data['type']
+        return PortTypes.slug_to_integer(slug)
+
+
+class RearPortTemplateImportForm(ComponentTemplateImportForm):
+    type = forms.ChoiceField(
+        choices=PortTypes.TYPE_CHOICES
+    )
+
+    class Meta:
+        model = RearPortTemplate
+        fields = [
+            'device_type', 'name', 'type', 'positions',
+        ]
+
+    def clean_type(self):
+        # Convert slug value to field integer value
+        slug = self.cleaned_data['type']
+        return PortTypes.slug_to_integer(slug)
+
+
+class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
+
+    class Meta:
+        model = DeviceBayTemplate
+        fields = [
+            'device_type', 'name',
+        ]
+
+
 #
 # Device roles
 #

+ 130 - 3
netbox/dcim/tests/test_views.py

@@ -3,10 +3,11 @@ import urllib.parse
 from django.test import Client, TestCase
 from django.urls import reverse
 
-from dcim.constants import CABLE_TYPE_CAT6, IFACE_TYPE_1GE_FIXED
+from dcim.constants import *
 from dcim.models import (
-    Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup,
-    RackReservation, RackRole, Site, Region, VirtualChassis,
+    Cable, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
+    FrontPortTemplate, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerPortTemplate,
+    PowerOutletTemplate, Rack, RackGroup, RackReservation, RackRole, RearPortTemplate, Site, Region, VirtualChassis,
 )
 from utilities.testing import create_test_user
 
@@ -221,6 +222,132 @@ class DeviceTypeTestCase(TestCase):
         response = self.client.get(devicetype.get_absolute_url())
         self.assertEqual(response.status_code, 200)
 
+    def test_devicetype_import(self):
+
+        IMPORT_DATA = """
+manufacturer: Generic
+model: TEST-1000
+slug: test-1000
+u_height: 2
+console-ports:
+  - name: Console Port 1
+  - name: Console Port 2
+  - name: Console Port 3
+console-server-ports:
+  - name: Console Server Port 1
+  - name: Console Server Port 2
+  - name: Console Server Port 3
+power-ports:
+  - name: Power Port 1
+  - name: Power Port 2
+  - name: Power Port 3
+power-outlets:
+  - name: Power Outlet 1
+    power_port: Power Port 1
+    feed_leg: 1
+  - name: Power Outlet 2
+    power_port: Power Port 1
+    feed_leg: 1
+  - name: Power Outlet 3
+    power_port: Power Port 1
+    feed_leg: 1
+interfaces:
+  - name: Interface 1
+    type: 1000base-t
+    mgmt_only: true
+  - name: Interface 2
+    type: 1000base-t
+  - name: Interface 3
+    type: 1000base-t
+rear-ports:
+  - name: Rear Port 1
+    type: 8p8c
+  - name: Rear Port 2
+    type: 8p8c
+  - name: Rear Port 3
+    type: 8p8c
+front-ports:
+  - name: Front Port 1
+    type: 8p8c
+    rear_port: Rear Port 1
+  - name: Front Port 2
+    type: 8p8c
+    rear_port: Rear Port 2
+  - name: Front Port 3
+    type: 8p8c
+    rear_port: Rear Port 3
+device-bays:
+  - name: Device Bay 1
+  - name: Device Bay 2
+  - name: Device Bay 3
+"""
+
+        # Create the manufacturer
+        Manufacturer(name='Generic', slug='generic').save()
+
+        # Authenticate as user with necessary permissions
+        user = create_test_user(username='testuser2', permissions=[
+            'dcim.view_devicetype',
+            'dcim.add_devicetype',
+            'dcim.add_consoleporttemplate',
+            'dcim.add_consoleserverporttemplate',
+            'dcim.add_powerporttemplate',
+            'dcim.add_poweroutlettemplate',
+            'dcim.add_interfacetemplate',
+            'dcim.add_frontporttemplate',
+            'dcim.add_rearporttemplate',
+            'dcim.add_devicebaytemplate',
+        ])
+        self.client.force_login(user)
+
+        form_data = {
+            'data': IMPORT_DATA,
+            'format': 'yaml'
+        }
+        response = self.client.post(reverse('dcim:devicetype_import'), data=form_data, follow=True)
+        self.assertEqual(response.status_code, 200)
+
+        dt = DeviceType.objects.get(model='TEST-1000')
+
+        # Verify all of the components were created
+        self.assertEqual(dt.consoleport_templates.count(), 3)
+        cp1 = ConsolePortTemplate.objects.first()
+        self.assertEqual(cp1.name, 'Console Port 1')
+
+        self.assertEqual(dt.consoleserverport_templates.count(), 3)
+        csp1 = ConsoleServerPortTemplate.objects.first()
+        self.assertEqual(csp1.name, 'Console Server Port 1')
+
+        self.assertEqual(dt.powerport_templates.count(), 3)
+        pp1 = PowerPortTemplate.objects.first()
+        self.assertEqual(pp1.name, 'Power Port 1')
+
+        self.assertEqual(dt.poweroutlet_templates.count(), 3)
+        po1 = PowerOutletTemplate.objects.first()
+        self.assertEqual(po1.name, 'Power Outlet 1')
+        self.assertEqual(po1.power_port, pp1)
+        self.assertEqual(po1.feed_leg, POWERFEED_LEG_A)
+
+        self.assertEqual(dt.interface_templates.count(), 3)
+        iface1 = InterfaceTemplate.objects.first()
+        self.assertEqual(iface1.name, 'Interface 1')
+        self.assertEqual(iface1.type, IFACE_TYPE_1GE_FIXED)
+        self.assertTrue(iface1.mgmt_only)
+
+        self.assertEqual(dt.rearport_templates.count(), 3)
+        rp1 = RearPortTemplate.objects.first()
+        self.assertEqual(rp1.name, 'Rear Port 1')
+
+        self.assertEqual(dt.frontport_templates.count(), 3)
+        fp1 = FrontPortTemplate.objects.first()
+        self.assertEqual(fp1.name, 'Front Port 1')
+        self.assertEqual(fp1.rear_port, rp1)
+        self.assertEqual(fp1.rear_port_position, 1)
+
+        self.assertEqual(dt.device_bay_templates.count(), 3)
+        db1 = DeviceBayTemplate.objects.first()
+        self.assertEqual(db1.name, 'Device Bay 1')
+
 
 class DeviceRoleTestCase(TestCase):
 

+ 1 - 1
netbox/dcim/urls.py

@@ -82,7 +82,7 @@ urlpatterns = [
     # Device types
     path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
     path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
-    path(r'device-types/import/', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'),
+    path(r'device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
     path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
     path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
     path(r'device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),

+ 27 - 6
netbox/dcim/views.py

@@ -1,3 +1,4 @@
+from collections import OrderedDict
 import re
 
 from django.conf import settings
@@ -26,7 +27,7 @@ from utilities.paginator import EnhancedPaginator
 from utilities.utils import csv_format
 from utilities.views import (
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
-    ObjectDeleteView, ObjectEditView, ObjectListView,
+    ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 from virtualization.models import VirtualMachine
 from . import filters, forms, tables
@@ -653,11 +654,31 @@ class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     default_return_url = 'dcim:devicetype_list'
 
 
-class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
-    permission_required = 'dcim.add_devicetype'
-    model_form = forms.DeviceTypeCSVForm
-    table = tables.DeviceTypeTable
-    default_return_url = 'dcim:devicetype_list'
+class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView):
+    permission_required = [
+        'dcim.add_devicetype',
+        'dcim.add_consoleporttemplate',
+        'dcim.add_consoleserverporttemplate',
+        'dcim.add_powerporttemplate',
+        'dcim.add_poweroutlettemplate',
+        'dcim.add_interfacetemplate',
+        'dcim.add_frontporttemplate',
+        'dcim.add_rearporttemplate',
+        'dcim.add_devicebaytemplate',
+    ]
+    model = DeviceType
+    model_form = forms.DeviceTypeImportForm
+    related_object_forms = OrderedDict((
+        ('console-ports', forms.ConsolePortTemplateImportForm),
+        ('console-server-ports', forms.ConsoleServerPortTemplateImportForm),
+        ('power-ports', forms.PowerPortTemplateImportForm),
+        ('power-outlets', forms.PowerOutletTemplateImportForm),
+        ('interfaces', forms.InterfaceTemplateImportForm),
+        ('rear-ports', forms.RearPortTemplateImportForm),
+        ('front-ports', forms.FrontPortTemplateImportForm),
+        ('device-bays', forms.DeviceBayTemplateImportForm),
+    ))
+    default_return_url = 'dcim:devicetype_import'
 
 
 class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):

+ 1 - 1
netbox/templates/dcim/device_import.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_import.html' %}
+{% extends 'utilities/obj_bulk_import.html' %}
 
 {% block tabs %}
     {% include 'dcim/inc/device_import_header.html' %}

+ 1 - 1
netbox/templates/dcim/device_import_child.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_import.html' %}
+{% extends 'utilities/obj_bulk_import.html' %}
 
 {% block tabs %}
     {% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %}

+ 1 - 1
netbox/templates/secrets/secret_import.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_import.html' %}
+{% extends 'utilities/obj_bulk_import.html' %}
 {% load static %}
 
 {% block content %}

+ 60 - 0
netbox/templates/utilities/obj_bulk_import.html

@@ -0,0 +1,60 @@
+{% extends '_base.html' %}
+{% load helpers %}
+{% load form_helpers %}
+
+{% block content %}
+<h1>{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}</h1>
+{% block tabs %}{% endblock %}
+<div class="row">
+	<div class="col-md-7">
+        {% if form.non_field_errors %}
+            <div class="panel panel-danger">
+                <div class="panel-heading"><strong>Errors</strong></div>
+                <div class="panel-body">
+                    {{ form.non_field_errors }}
+                </div>
+            </div>
+        {% endif %}
+		<form action="" method="post" class="form">
+		    {% csrf_token %}
+		    {% render_form form %}
+            <div class="form-group">
+                <div class="col-md-12 text-right">
+		            <button type="submit" class="btn btn-primary">Submit</button>
+		            {% if return_url %}
+                        <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+                    {% endif %}
+                </div>
+            </div>
+		</form>
+	</div>
+	<div class="col-md-5">
+        {% if fields %}
+            <h4 class="text-center">CSV Format</h4>
+            <table class="table">
+                <tr>
+                    <th>Field</th>
+                    <th>Required</th>
+                    <th>Description</th>
+                </tr>
+                {% for name, field in fields.items %}
+                    <tr>
+                        <td><code>{{ name }}</code></td>
+                        <td>{% if field.required %}<i class="glyphicon glyphicon-ok" title="Required"></i>{% endif %}</td>
+                        <td>
+                            {{ field.help_text|default:field.label }}
+                            {% if field.choices %}
+                                <br /><small class="text-muted">Choices: {{ field|example_choices }}</small>
+                            {% elif field|widget_type == 'dateinput' %}
+                                <br /><small class="text-muted">Format: YYYY-MM-DD</small>
+                            {% elif field|widget_type == 'checkboxinput' %}
+                                <br /><small class="text-muted">Specify "true" or "false"</small>
+                            {% endif %}
+                        </td>
+                    </tr>
+                {% endfor %}
+            </table>
+        {% endif %}
+	</div>
+</div>
+{% endblock %}

+ 4 - 31
netbox/templates/utilities/obj_import.html

@@ -6,7 +6,7 @@
 <h1>{% block title %}{{ obj_type|bettertitle }} Import{% endblock %}</h1>
 {% block tabs %}{% endblock %}
 <div class="row">
-	<div class="col-md-7">
+	<div class="col-md-8 col-md-offset-2">
         {% if form.non_field_errors %}
             <div class="panel panel-danger">
                 <div class="panel-heading"><strong>Errors</strong></div>
@@ -15,12 +15,13 @@
                 </div>
             </div>
         {% endif %}
-		<form action="" method="post" class="form">
+		<form action="" method="post" class="form form-horizontal">
 		    {% csrf_token %}
 		    {% render_form form %}
             <div class="form-group">
                 <div class="col-md-12 text-right">
-		            <button type="submit" class="btn btn-primary">Submit</button>
+                    <button type="submit" name="_create" class="btn btn-primary">Submit</button>
+                    <button type="submit" name="_addanother" class="btn btn-primary">Submit and Import Another</button>
 		            {% if return_url %}
                         <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                     {% endif %}
@@ -28,33 +29,5 @@
             </div>
 		</form>
 	</div>
-	<div class="col-md-5">
-        {% if fields %}
-            <h4 class="text-center">CSV Format</h4>
-            <table class="table">
-                <tr>
-                    <th>Field</th>
-                    <th>Required</th>
-                    <th>Description</th>
-                </tr>
-                {% for name, field in fields.items %}
-                    <tr>
-                        <td><code>{{ name }}</code></td>
-                        <td>{% if field.required %}<i class="glyphicon glyphicon-ok" title="Required"></i>{% endif %}</td>
-                        <td>
-                            {{ field.help_text|default:field.label }}
-                            {% if field.choices %}
-                                <br /><small class="text-muted">Choices: {{ field|example_choices }}</small>
-                            {% elif field|widget_type == 'dateinput' %}
-                                <br /><small class="text-muted">Format: YYYY-MM-DD</small>
-                            {% elif field|widget_type == 'checkboxinput' %}
-                                <br /><small class="text-muted">Specify "true" or "false"</small>
-                            {% endif %}
-                        </td>
-                    </tr>
-                {% endfor %}
-            </table>
-        {% endif %}
-	</div>
 </div>
 {% endblock %}

+ 39 - 0
netbox/utilities/forms.py

@@ -2,6 +2,7 @@ import csv
 import json
 import re
 from io import StringIO
+import yaml
 
 from django import forms
 from django.conf import settings
@@ -722,3 +723,41 @@ class BulkEditForm(forms.Form):
         # Copy any nullable fields defined in Meta
         if hasattr(self.Meta, 'nullable_fields'):
             self.nullable_fields = self.Meta.nullable_fields
+
+
+class ImportForm(BootstrapMixin, forms.Form):
+    """
+    Generic form for creating an object from JSON/YAML data
+    """
+    data = forms.CharField(
+        widget=forms.Textarea,
+        help_text="Enter object data in JSON or YAML format."
+    )
+    format = forms.ChoiceField(
+        choices=(
+            ('json', 'JSON'),
+            ('yaml', 'YAML')
+        ),
+        initial='yaml'
+    )
+
+    def clean(self):
+
+        data = self.cleaned_data['data']
+        format = self.cleaned_data['format']
+
+        # Process JSON/YAML data
+        if format == 'json':
+            try:
+                self.cleaned_data['data'] = json.loads(data)
+            except json.decoder.JSONDecodeError as err:
+                raise forms.ValidationError({
+                    'data': "Invalid JSON data: {}".format(err)
+                })
+        else:
+            try:
+                self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader)
+            except yaml.scanner.ScannerError as err:
+                raise forms.ValidationError({
+                    'data': "Invalid YAML data: {}".format(err)
+                })

+ 105 - 2
netbox/utilities/views.py

@@ -1,4 +1,6 @@
+import json
 import sys
+import yaml
 from copy import deepcopy
 
 from django.conf import settings
@@ -24,10 +26,11 @@ from django_tables2 import RequestConfig
 
 from extras.models import CustomField, CustomFieldValue, ExportTemplate
 from extras.querysets import CustomFieldQueryset
+from utilities.exceptions import AbortTransaction
 from utilities.forms import BootstrapMixin, CSVDataField
 from utilities.utils import csv_format
 from .error_handlers import handle_protectederror
-from .forms import ConfirmationForm
+from .forms import ConfirmationForm, ImportForm
 from .paginator import EnhancedPaginator
 
 
@@ -394,6 +397,106 @@ class BulkCreateView(GetReturnURLMixin, View):
         })
 
 
+class ObjectImportView(GetReturnURLMixin, View):
+    """
+    Import a single object (YAML or JSON format).
+    """
+    model = None
+    model_form = None
+    related_object_forms = dict()
+    template_name = 'utilities/obj_import.html'
+
+    def get(self, request):
+
+        form = ImportForm()
+
+        return render(request, self.template_name, {
+            'form': form,
+            'obj_type': self.model._meta.verbose_name,
+            'return_url': self.get_return_url(request),
+        })
+
+    def post(self, request):
+
+        form = ImportForm(request.POST)
+        if form.is_valid():
+
+            # Initialize model form
+            data = form.cleaned_data['data']
+            model_form = self.model_form(data)
+
+            # Assign default values for any fields which were not specified. We have to do this manually because passing
+            # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not
+            # used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the
+            # applicable field defaults as needed prior to form validation.
+            for field_name, field in model_form.fields.items():
+                if field_name not in data and hasattr(field, 'initial'):
+                    model_form.data[field_name] = field.initial
+
+            if model_form.is_valid():
+
+                try:
+                    with transaction.atomic():
+
+                        # Save the primary object
+                        obj = model_form.save()
+
+                        # Iterate through the related object forms (if any), validating and saving each instance.
+                        for field_name, related_object_form in self.related_object_forms.items():
+
+                            for i, rel_obj_data in enumerate(data.get(field_name, list())):
+
+                                f = related_object_form(obj, rel_obj_data)
+
+                                for subfield_name, field in f.fields.items():
+                                    if subfield_name not in rel_obj_data and hasattr(field, 'initial'):
+                                        f.data[subfield_name] = field.initial
+
+                                if f.is_valid():
+                                    f.save()
+                                else:
+                                    # Replicate errors on the related object form to the primary form for display
+                                    for subfield_name, errors in f.errors.items():
+                                        for err in errors:
+                                            err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
+                                            model_form.add_error(None, err_msg)
+                                    raise AbortTransaction()
+
+                except AbortTransaction:
+                    pass
+
+            if not model_form.errors:
+
+                messages.success(request, mark_safe('Imported object: <a href="{}">{}</a>'.format(
+                    obj.get_absolute_url(), obj
+                )))
+
+                if '_addanother' in request.POST:
+                    return redirect(request.get_full_path())
+
+                return_url = form.cleaned_data.get('return_url')
+                if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
+                    return redirect(return_url)
+                else:
+                    return redirect(self.get_return_url(request, obj))
+
+            else:
+
+                # Replicate model form errors for display
+                for field, errors in model_form.errors.items():
+                    for err in errors:
+                        if field == '__all__':
+                            form.add_error(None, err)
+                        else:
+                            form.add_error(None, "{}: {}".format(field, err))
+
+        return render(request, self.template_name, {
+            'form': form,
+            'obj_type': self.model._meta.verbose_name,
+            'return_url': self.get_return_url(request),
+        })
+
+
 class BulkImportView(GetReturnURLMixin, View):
     """
     Import objects in bulk (CSV format).
@@ -405,7 +508,7 @@ class BulkImportView(GetReturnURLMixin, View):
     """
     model_form = None
     table = None
-    template_name = 'utilities/obj_import.html'
+    template_name = 'utilities/obj_bulk_import.html'
     widget_attrs = {}
 
     def _import_form(self, *args, **kwargs):