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

Merge pull request #8176 from netbox-community/7846-inventoryitem-component

Closes #7846: Associate inventory items with device components
Jeremy Stretch 4 лет назад
Родитель
Сommit
e9910d1fe2
31 измененных файлов с 545 добавлено и 902 удалено
  1. 3 1
      docs/release-notes/version-3.2.md
  2. 17 2
      netbox/dcim/api/serializers.py
  3. 18 3
      netbox/dcim/constants.py
  4. 2 0
      netbox/dcim/filtersets.py
  5. 2 2
      netbox/dcim/forms/bulk_create.py
  6. 82 68
      netbox/dcim/forms/models.py
  7. 67 544
      netbox/dcim/forms/object_create.py
  8. 23 0
      netbox/dcim/migrations/0147_inventoryitem_component.py
  9. 22 0
      netbox/dcim/models/device_components.py
  10. 10 5
      netbox/dcim/tables/devices.py
  11. 16 3
      netbox/dcim/tests/test_api.py
  12. 13 3
      netbox/dcim/tests/test_filtersets.py
  13. 8 22
      netbox/dcim/tests/test_forms.py
  14. 44 44
      netbox/dcim/views.py
  15. 27 11
      netbox/netbox/views/generic/object_views.py
  16. 7 0
      netbox/templates/dcim/component_create.html
  17. 80 79
      netbox/templates/dcim/consoleport.html
  18. 1 0
      netbox/templates/dcim/consoleserverport.html
  19. 1 0
      netbox/templates/dcim/frontport.html
  20. 59 0
      netbox/templates/dcim/inc/panels/inventory_items.html
  21. 1 0
      netbox/templates/dcim/interface.html
  22. 0 16
      netbox/templates/dcim/interface_create.html
  23. 10 0
      netbox/templates/dcim/inventoryitem.html
  24. 1 0
      netbox/templates/dcim/poweroutlet.html
  25. 1 0
      netbox/templates/dcim/powerport.html
  26. 1 0
      netbox/templates/dcim/rearport.html
  27. 15 9
      netbox/templates/generic/object_edit.html
  28. 1 5
      netbox/utilities/forms/fields.py
  29. 10 14
      netbox/virtualization/forms/models.py
  30. 2 70
      netbox/virtualization/forms/object_create.py
  31. 1 1
      netbox/virtualization/views.py

+ 3 - 1
docs/release-notes/version-3.2.md

@@ -48,6 +48,7 @@ FIELD_CHOICES = {
 * [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks
 * [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form
 * [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts
+* [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components
 * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group
 
 ### Other Changes
@@ -76,7 +77,8 @@ FIELD_CHOICES = {
 * dcim.Interface
     * Added `module` field
 * dcim.InventoryItem
-    * Added `role` field
+    * Added `component_type`, `component_id`, and `role` fields
+    * Added read-only `component` field
 * dcim.PowerPort
     * Added `module` field
 * dcim.PowerOutlet

+ 17 - 2
netbox/dcim/api/serializers.py

@@ -810,17 +810,32 @@ class InventoryItemSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
     device = NestedDeviceSerializer()
     parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
-    manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
     role = NestedInventoryItemRoleSerializer(required=False, allow_null=True)
+    manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
+    component_type = ContentTypeField(
+        queryset=ContentType.objects.filter(MODULAR_COMPONENT_MODELS),
+        required=False,
+        allow_null=True
+    )
+    component = serializers.SerializerMethodField(read_only=True)
     _depth = serializers.IntegerField(source='level', read_only=True)
 
     class Meta:
         model = InventoryItem
         fields = [
             'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial',
-            'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
+            'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
+            'custom_fields', 'created', 'last_updated', '_depth',
         ]
 
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
+    def get_component(self, obj):
+        if obj.component is None:
+            return None
+        serializer = get_serializer_for_model(obj.component, prefix='Nested')
+        context = {'request': self.context['request']}
+        return serializer(obj.component, context=context).data
+
 
 #
 # Device component roles

+ 18 - 3
netbox/dcim/constants.py

@@ -50,16 +50,31 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
 
 
 #
-# PowerFeeds
+# Power feeds
 #
 
 POWERFEED_VOLTAGE_DEFAULT = 120
-
 POWERFEED_AMPERAGE_DEFAULT = 20
-
 POWERFEED_MAX_UTILIZATION_DEFAULT = 80  # Percentage
 
 
+#
+# Device components
+#
+
+MODULAR_COMPONENT_MODELS = Q(
+    app_label='dcim',
+    model__in=(
+        'consoleport',
+        'consoleserverport',
+        'frontport',
+        'interface',
+        'poweroutlet',
+        'powerport',
+        'rearport',
+    ))
+
+
 #
 # Cabling and connections
 #

+ 2 - 0
netbox/dcim/filtersets.py

@@ -1294,6 +1294,8 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
         to_field_name='slug',
         label='Role (slug)',
     )
+    component_type = ContentTypeFilter()
+    component_id = MultiValueNumberFilter()
     serial = django_filters.CharFilter(
         lookup_expr='iexact'
     )

+ 2 - 2
netbox/dcim/forms/bulk_create.py

@@ -4,7 +4,7 @@ from dcim.models import *
 from extras.forms import CustomFieldsMixin
 from extras.models import Tag
 from utilities.forms import DynamicModelMultipleChoiceField, form_from_model
-from .object_create import ComponentForm
+from .object_create import ComponentCreateForm
 
 __all__ = (
     'ConsolePortBulkCreateForm',
@@ -24,7 +24,7 @@ __all__ = (
 # Device components
 #
 
-class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentForm):
+class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Device.objects.all(),
         widget=forms.MultipleHiddenInput()

+ 82 - 68
netbox/dcim/forms/models.py

@@ -12,8 +12,8 @@ from extras.models import Tag
 from ipam.models import IPAddress, VLAN, VLANGroup, ASN
 from tenancy.forms import TenancyForm
 from utilities.forms import (
-    APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField,
-    DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea,
+    APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
+    DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea,
     SlugField, StaticSelect,
 )
 from virtualization.models import Cluster, ClusterGroup
@@ -957,6 +957,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
         widgets = {
             'device_type': forms.HiddenInput(),
             'module_type': forms.HiddenInput(),
+            'type': StaticSelect,
         }
 
 
@@ -969,6 +970,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
         widgets = {
             'device_type': forms.HiddenInput(),
             'module_type': forms.HiddenInput(),
+            'type': StaticSelect,
         }
 
 
@@ -981,10 +983,19 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
         widgets = {
             'device_type': forms.HiddenInput(),
             'module_type': forms.HiddenInput(),
+            'type': StaticSelect(),
         }
 
 
 class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
+    power_port = DynamicModelChoiceField(
+        queryset=PowerPortTemplate.objects.all(),
+        required=False,
+        query_params={
+            'devicetype_id': '$device_type',
+        }
+    )
+
     class Meta:
         model = PowerOutletTemplate
         fields = [
@@ -993,18 +1004,10 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
         widgets = {
             'device_type': forms.HiddenInput(),
             'module_type': forms.HiddenInput(),
+            'type': StaticSelect(),
+            'feed_leg': StaticSelect(),
         }
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port choices to current DeviceType/ModuleType
-        if self.instance.pk:
-            self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(
-                device_type=self.instance.device_type,
-                module_type=self.instance.module_type
-            )
-
 
 class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
@@ -1020,6 +1023,14 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
 
 
 class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
+    rear_port = DynamicModelChoiceField(
+        queryset=RearPortTemplate.objects.all(),
+        required=False,
+        query_params={
+            'devicetype_id': '$device_type',
+        }
+    )
+
     class Meta:
         model = FrontPortTemplate
         fields = [
@@ -1029,19 +1040,9 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
         widgets = {
             'device_type': forms.HiddenInput(),
             'module_type': forms.HiddenInput(),
-            'rear_port': StaticSelect(),
+            'type': StaticSelect(),
         }
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit rear_port choices to current DeviceType/ModuleType
-        if self.instance.pk:
-            self.fields['rear_port'].queryset = RearPortTemplate.objects.filter(
-                device_type=self.instance.device_type,
-                module_type=self.instance.module_type
-            )
-
 
 class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
@@ -1095,6 +1096,8 @@ class ConsolePortForm(CustomFieldModelForm):
         ]
         widgets = {
             'device': forms.HiddenInput(),
+            'type': StaticSelect(),
+            'speed': StaticSelect(),
         }
 
 
@@ -1111,6 +1114,8 @@ class ConsoleServerPortForm(CustomFieldModelForm):
         ]
         widgets = {
             'device': forms.HiddenInput(),
+            'type': StaticSelect(),
+            'speed': StaticSelect(),
         }
 
 
@@ -1128,13 +1133,17 @@ class PowerPortForm(CustomFieldModelForm):
         ]
         widgets = {
             'device': forms.HiddenInput(),
+            'type': StaticSelect(),
         }
 
 
 class PowerOutletForm(CustomFieldModelForm):
-    power_port = forms.ModelChoiceField(
+    power_port = DynamicModelChoiceField(
         queryset=PowerPort.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'device_id': '$device',
+        }
     )
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
@@ -1148,34 +1157,34 @@ class PowerOutletForm(CustomFieldModelForm):
         ]
         widgets = {
             'device': forms.HiddenInput(),
+            'type': StaticSelect(),
+            'feed_leg': StaticSelect(),
         }
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port choices to the local device
-        if hasattr(self.instance, 'device'):
-            self.fields['power_port'].queryset = PowerPort.objects.filter(
-                device=self.instance.device
-            )
-
 
 class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
     parent = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         required=False,
-        label='Parent interface'
+        label='Parent interface',
+        query_params={
+            'device_id': '$device',
+        }
     )
     bridge = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         required=False,
-        label='Bridged interface'
+        label='Bridged interface',
+        query_params={
+            'device_id': '$device',
+        }
     )
     lag = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         required=False,
         label='LAG interface',
         query_params={
+            'device_id': '$device',
             'type': 'lag',
         }
     )
@@ -1203,6 +1212,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         label='Untagged VLAN',
         query_params={
             'group_id': '$vlan_group',
+            'available_on_device': '$device',
         }
     )
     tagged_vlans = DynamicModelMultipleChoiceField(
@@ -1211,6 +1221,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         label='Tagged VLANs',
         query_params={
             'group_id': '$vlan_group',
+            'available_on_device': '$device',
         }
     )
     tags = DynamicModelMultipleChoiceField(
@@ -1225,6 +1236,17 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
             'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
             'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags',
         ]
+        fieldsets = (
+            ('Interface', ('device', 'name', 'type', 'label', 'description', 'tags')),
+            ('Addressing', ('mac_address', 'wwn')),
+            ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
+            ('Related Interfaces', ('parent', 'bridge', 'lag')),
+            ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
+            ('Wireless', (
+                'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group',
+                'wireless_lans',
+            )),
+        )
         widgets = {
             'device': forms.HiddenInput(),
             'type': StaticSelect(),
@@ -1241,26 +1263,14 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
             'rf_channel_width': "Populated by selected channel (if set)",
         }
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device
-
-        # Restrict parent/bridge/LAG interface assignment by device/VC
-        self.fields['parent'].widget.add_query_param('device_id', device.pk)
-        self.fields['bridge'].widget.add_query_param('device_id', device.pk)
-        self.fields['lag'].widget.add_query_param('device_id', device.pk)
-        if device.virtual_chassis and device.virtual_chassis.master:
-            self.fields['parent'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
-            self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
-            self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
-
-        # Limit VLAN choices by device
-        self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
-        self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
-
 
 class FrontPortForm(CustomFieldModelForm):
+    rear_port = DynamicModelChoiceField(
+        queryset=RearPort.objects.all(),
+        query_params={
+            'device_id': '$device',
+        }
+    )
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -1275,18 +1285,8 @@ class FrontPortForm(CustomFieldModelForm):
         widgets = {
             'device': forms.HiddenInput(),
             'type': StaticSelect(),
-            'rear_port': StaticSelect(),
         }
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit RearPort choices to the local device
-        if hasattr(self.instance, 'device'):
-            self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter(
-                device=self.instance.device
-            )
-
 
 class RearPortForm(CustomFieldModelForm):
     tags = DynamicModelMultipleChoiceField(
@@ -1358,9 +1358,6 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
 
 
 class InventoryItemForm(CustomFieldModelForm):
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.all()
-    )
     parent = DynamicModelChoiceField(
         queryset=InventoryItem.objects.all(),
         required=False,
@@ -1376,6 +1373,15 @@ class InventoryItemForm(CustomFieldModelForm):
         queryset=Manufacturer.objects.all(),
         required=False
     )
+    component_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=MODULAR_COMPONENT_MODELS,
+        required=False,
+        widget=StaticSelect
+    )
+    component_id = forms.IntegerField(
+        required=False
+    )
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -1385,8 +1391,16 @@ class InventoryItemForm(CustomFieldModelForm):
         model = InventoryItem
         fields = [
             'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
-            'description', 'tags',
+            'description', 'component_type', 'component_id', 'tags',
         ]
+        fieldsets = (
+            ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')),
+            ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
+            ('Component', ('component_type', 'component_id')),
+        )
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
 
 
 #

+ 67 - 544
netbox/dcim/forms/object_create.py

@@ -1,43 +1,21 @@
 from django import forms
 
-from dcim.choices import *
-from dcim.constants import *
 from dcim.models import *
-from extras.forms import CustomFieldModelForm, CustomFieldsMixin
+from extras.forms import CustomFieldModelForm
 from extras.models import Tag
-from ipam.models import VLAN
 from utilities.forms import (
-    add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    ExpandableNameField, StaticSelect,
+    BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
 )
-from wireless.choices import *
-from .common import InterfaceCommonForm
 
 __all__ = (
-    'ConsolePortCreateForm',
-    'ConsolePortTemplateCreateForm',
-    'ConsoleServerPortCreateForm',
-    'ConsoleServerPortTemplateCreateForm',
-    'DeviceBayCreateForm',
-    'DeviceBayTemplateCreateForm',
+    'ComponentCreateForm',
     'FrontPortCreateForm',
     'FrontPortTemplateCreateForm',
-    'InterfaceCreateForm',
-    'InterfaceTemplateCreateForm',
-    'InventoryItemCreateForm',
-    'ModuleBayCreateForm',
-    'ModuleBayTemplateCreateForm',
-    'PowerOutletCreateForm',
-    'PowerOutletTemplateCreateForm',
-    'PowerPortCreateForm',
-    'PowerPortTemplateCreateForm',
-    'RearPortCreateForm',
-    'RearPortTemplateCreateForm',
     'VirtualChassisCreateForm',
 )
 
 
-class ComponentForm(BootstrapMixin, forms.Form):
+class ComponentCreateForm(BootstrapMixin, forms.Form):
     """
     Subclass this form when facilitating the creation of one or more device component or component templates based on
     a name pattern.
@@ -65,215 +43,14 @@ class ComponentForm(BootstrapMixin, forms.Form):
                 }, code='label_pattern_mismatch')
 
 
-class VirtualChassisCreateForm(CustomFieldModelForm):
-    region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        initial_params={
-            'sites': '$site'
-        }
-    )
-    site_group = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        initial_params={
-            'sites': '$site'
-        }
-    )
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
-    rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site'
-        }
-    )
-    members = DynamicModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        query_params={
-            'site_id': '$site',
-            'rack_id': '$rack',
-        }
-    )
-    initial_position = forms.IntegerField(
-        initial=1,
-        required=False,
-        help_text='Position of the first member device. Increases by one for each additional member.'
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-    class Meta:
-        model = VirtualChassis
-        fields = [
-            'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
-        ]
-
-    def clean(self):
-        if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
-            raise forms.ValidationError({
-                'initial_position': "A position must be specified for the first VC member."
-            })
-
-    def save(self, *args, **kwargs):
-        instance = super().save(*args, **kwargs)
-
-        # Assign VC members
-        if instance.pk and self.cleaned_data['members']:
-            initial_position = self.cleaned_data.get('initial_position', 1)
-            for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
-                member.virtual_chassis = instance
-                member.vc_position = i
-                member.save()
-
-        return instance
-
-
-#
-# Component templates
-#
-
-class ComponentTemplateCreateForm(ComponentForm):
-    """
-    Base form for the creation of device component templates (subclassed from ComponentTemplateModel).
-    """
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False,
-        initial_params={
-            'device_types': 'device_type',
-            'module_types': 'module_type',
-        }
-    )
-    device_type = DynamicModelChoiceField(
-        queryset=DeviceType.objects.all(),
-        required=False,
-        query_params={
-            'manufacturer_id': '$manufacturer'
-        }
-    )
-    description = forms.CharField(
-        required=False
-    )
-
-
-class ModularComponentTemplateCreateForm(ComponentTemplateCreateForm):
-    module_type = DynamicModelChoiceField(
-        queryset=ModuleType.objects.all(),
-        required=False,
-        query_params={
-            'manufacturer_id': '$manufacturer'
-        }
-    )
-
-
-class ConsolePortTemplateCreateForm(ModularComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        widget=StaticSelect()
-    )
-    field_order = (
-        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'description',
-    )
-
-
-class ConsoleServerPortTemplateCreateForm(ModularComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        widget=StaticSelect()
-    )
-    field_order = (
-        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'description',
-    )
-
-
-class PowerPortTemplateCreateForm(ModularComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerPortTypeChoices),
-        required=False
-    )
-    maximum_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Maximum power draw (watts)"
-    )
-    allocated_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Allocated power draw (watts)"
-    )
-    field_order = (
-        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw',
-        'allocated_draw', 'description',
-    )
-
-
-class PowerOutletTemplateCreateForm(ModularComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletTypeChoices),
-        required=False
-    )
-    power_port = DynamicModelChoiceField(
-        queryset=PowerPortTemplate.objects.all(),
-        required=False,
-        query_params={
-            'devicetype_id': '$device_type',
-            'moduletype_id': '$module_type',
-        }
-    )
-    feed_leg = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletFeedLegChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    field_order = (
-        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg',
-        'description',
-    )
-
-
-class InterfaceTemplateCreateForm(ModularComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=InterfaceTypeChoices,
-        widget=StaticSelect()
-    )
-    mgmt_only = forms.BooleanField(
-        required=False,
-        label='Management only'
-    )
-    field_order = (
-        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only',
-        'description',
-    )
-
-
-class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=PortTypeChoices,
-        widget=StaticSelect()
-    )
-    color = ColorField(
-        required=False
-    )
+class FrontPortTemplateCreateForm(ComponentCreateForm):
     rear_port_set = forms.MultipleChoiceField(
         choices=[],
         label='Rear ports',
         help_text='Select one rear port assignment for each front port being created.',
     )
     field_order = (
-        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set',
-        'description',
+        'name_pattern', 'label_pattern', 'rear_port_set',
     )
 
     def __init__(self, *args, **kwargs):
@@ -300,18 +77,6 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
                     )
         self.fields['rear_port_set'].choices = choices
 
-    def clean(self):
-        super().clean()
-
-        # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
-        front_port_count = len(self.cleaned_data['name_pattern'])
-        rear_port_count = len(self.cleaned_data['rear_port_set'])
-        if front_port_count != rear_port_count:
-            raise forms.ValidationError({
-                'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
-                                 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
-            })
-
     def get_iterative_data(self, iteration):
 
         # Assign rear port and position from selected set
@@ -323,252 +88,14 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
         }
 
 
-class RearPortTemplateCreateForm(ModularComponentTemplateCreateForm):
-    type = forms.ChoiceField(
-        choices=PortTypeChoices,
-        widget=StaticSelect(),
-    )
-    color = ColorField(
-        required=False
-    )
-    positions = forms.IntegerField(
-        min_value=REARPORT_POSITIONS_MIN,
-        max_value=REARPORT_POSITIONS_MAX,
-        initial=1,
-        help_text='The number of front ports which may be mapped to each rear port'
-    )
-    field_order = (
-        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions',
-        'description',
-    )
-
-
-class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm):
-    # TODO: Support patterned position assignment
-    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description')
-
-
-class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
-    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description')
-
-
-#
-# Device components
-#
-
-class ComponentCreateForm(CustomFieldsMixin, ComponentForm):
-    """
-    Base form for the creation of device components (models subclassed from ComponentModel).
-    """
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.all()
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-
-
-class ConsolePortCreateForm(ComponentCreateForm):
-    model = ConsolePort
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    speed = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortSpeedChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags')
-
-
-class ConsoleServerPortCreateForm(ComponentCreateForm):
-    model = ConsoleServerPort
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    speed = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortSpeedChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags')
-
-
-class PowerPortCreateForm(ComponentCreateForm):
-    model = PowerPort
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerPortTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    maximum_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Maximum draw in watts"
-    )
-    allocated_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Allocated draw in watts"
-    )
-    field_order = (
-        'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
-        'description', 'tags',
-    )
-
-
-class PowerOutletCreateForm(ComponentCreateForm):
-    model = PowerOutlet
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletTypeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    power_port = forms.ModelChoiceField(
-        queryset=PowerPort.objects.all(),
-        required=False
-    )
-    feed_leg = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletFeedLegChoices),
-        required=False
-    )
-    field_order = (
-        'device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
-        'tags',
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port queryset to PowerPorts which belong to the parent Device
-        device = Device.objects.get(
-            pk=self.initial.get('device') or self.data.get('device')
-        )
-        self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
-
-
-class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
-    model = Interface
-    type = forms.ChoiceField(
-        choices=InterfaceTypeChoices,
-        widget=StaticSelect(),
-    )
-    enabled = forms.BooleanField(
-        required=False,
-        initial=True
-    )
-    parent = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device',
-        }
-    )
-    bridge = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device',
-        }
-    )
-    lag = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device',
-            'type': 'lag',
-        },
-        label='LAG'
-    )
-    mac_address = forms.CharField(
-        required=False,
-        label='MAC Address'
-    )
-    wwn = forms.CharField(
-        required=False,
-        label='WWN'
-    )
-    mgmt_only = forms.BooleanField(
-        required=False,
-        label='Management only',
-        help_text='This interface is used only for out-of-band management'
-    )
-    mode = forms.ChoiceField(
-        choices=add_blank_choice(InterfaceModeChoices),
-        required=False,
-        widget=StaticSelect()
-    )
-    rf_role = forms.ChoiceField(
-        choices=add_blank_choice(WirelessRoleChoices),
-        required=False,
-        widget=StaticSelect(),
-        label='Wireless role'
-    )
-    rf_channel = forms.ChoiceField(
-        choices=add_blank_choice(WirelessChannelChoices),
-        required=False,
-        widget=StaticSelect(),
-        label='Wireless channel'
-    )
-    rf_channel_frequency = forms.DecimalField(
-        required=False,
-        label='Channel frequency (MHz)'
-    )
-    rf_channel_width = forms.DecimalField(
-        required=False,
-        label='Channel width (MHz)'
-    )
-    untagged_vlan = DynamicModelChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        label='Untagged VLAN'
-    )
-    tagged_vlans = DynamicModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        label='Tagged VLANs'
-    )
-    field_order = (
-        'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address',
-        'wwn', 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency',
-        'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit VLAN choices by device
-        device_id = self.initial.get('device') or self.data.get('device')
-        self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id)
-        self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id)
-
-
 class FrontPortCreateForm(ComponentCreateForm):
-    model = FrontPort
-    type = forms.ChoiceField(
-        choices=PortTypeChoices,
-        widget=StaticSelect(),
-    )
-    color = ColorField(
-        required=False
-    )
     rear_port_set = forms.MultipleChoiceField(
         choices=[],
         label='Rear ports',
         help_text='Select one rear port assignment for each front port being created.',
     )
     field_order = (
-        'device', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'mark_connected', 'description',
-        'tags',
+        'name_pattern', 'label_pattern', 'rear_port_set',
     )
 
     def __init__(self, *args, **kwargs):
@@ -596,18 +123,6 @@ class FrontPortCreateForm(ComponentCreateForm):
                     )
         self.fields['rear_port_set'].choices = choices
 
-    def clean(self):
-        super().clean()
-
-        # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
-        front_port_count = len(self.cleaned_data['name_pattern'])
-        rear_port_count = len(self.cleaned_data['rear_port_set'])
-        if front_port_count != rear_port_count:
-            raise forms.ValidationError({
-                'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
-                                 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
-            })
-
     def get_iterative_data(self, iteration):
 
         # Assign rear port and position from selected set
@@ -619,68 +134,76 @@ class FrontPortCreateForm(ComponentCreateForm):
         }
 
 
-class RearPortCreateForm(ComponentCreateForm):
-    model = RearPort
-    type = forms.ChoiceField(
-        choices=PortTypeChoices,
-        widget=StaticSelect(),
-    )
-    color = ColorField(
-        required=False
-    )
-    positions = forms.IntegerField(
-        min_value=REARPORT_POSITIONS_MIN,
-        max_value=REARPORT_POSITIONS_MAX,
-        initial=1,
-        help_text='The number of front ports which may be mapped to each rear port'
+class VirtualChassisCreateForm(CustomFieldModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
     )
-    field_order = (
-        'device', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'mark_connected', 'description',
-        'tags',
+    site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
     )
-
-
-class ModuleBayCreateForm(ComponentCreateForm):
-    model = ModuleBay
-    field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags')
-
-
-class DeviceBayCreateForm(ComponentCreateForm):
-    model = DeviceBay
-    field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags')
-
-
-class InventoryItemCreateForm(ComponentCreateForm):
-    model = InventoryItem
-    parent = DynamicModelChoiceField(
-        queryset=InventoryItem.objects.all(),
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
         required=False,
         query_params={
-            'device_id': '$device'
+            'region_id': '$region',
+            'group_id': '$site_group',
         }
     )
-    role = DynamicModelChoiceField(
-        queryset=InventoryItemRole.objects.all(),
-        required=False
-    )
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False
-    )
-    part_id = forms.CharField(
-        max_length=50,
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
         required=False,
-        label='Part ID'
+        null_option='None',
+        query_params={
+            'site_id': '$site'
+        }
     )
-    serial = forms.CharField(
-        max_length=50,
+    members = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
         required=False,
+        query_params={
+            'site_id': '$site',
+            'rack_id': '$rack',
+        }
     )
-    asset_tag = forms.CharField(
-        max_length=50,
+    initial_position = forms.IntegerField(
+        initial=1,
         required=False,
+        help_text='Position of the first member device. Increases by one for each additional member.'
     )
-    field_order = (
-        'device', 'parent', 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
-        'description', 'tags',
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
     )
+
+    class Meta:
+        model = VirtualChassis
+        fields = [
+            'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
+        ]
+
+    def clean(self):
+        if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
+            raise forms.ValidationError({
+                'initial_position': "A position must be specified for the first VC member."
+            })
+
+    def save(self, *args, **kwargs):
+        instance = super().save(*args, **kwargs)
+
+        # Assign VC members
+        if instance.pk and self.cleaned_data['members']:
+            initial_position = self.cleaned_data.get('initial_position', 1)
+            for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
+                member.virtual_chassis = instance
+                member.vc_position = i
+                member.save()
+
+        return instance

+ 23 - 0
netbox/dcim/migrations/0147_inventoryitem_component.py

@@ -0,0 +1,23 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('dcim', '0146_inventoryitemrole'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='component_id',
+            field=models.PositiveBigIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='component_type',
+            field=models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'),
+        ),
+    ]

+ 22 - 0
netbox/dcim/models/device_components.py

@@ -97,6 +97,12 @@ class ModularComponentModel(ComponentModel):
         blank=True,
         null=True
     )
+    inventory_items = GenericRelation(
+        to='dcim.InventoryItem',
+        content_type_field='component_type',
+        object_id_field='component_id',
+        related_name='%(class)ss',
+    )
 
     class Meta:
         abstract = True
@@ -994,6 +1000,22 @@ class InventoryItem(MPTTModel, ComponentModel):
         null=True,
         db_index=True
     )
+    component_type = models.ForeignKey(
+        to=ContentType,
+        limit_choices_to=MODULAR_COMPONENT_MODELS,
+        on_delete=models.PROTECT,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    component_id = models.PositiveBigIntegerField(
+        blank=True,
+        null=True
+    )
+    component = GenericForeignKey(
+        ct_field='component_type',
+        fk_field='component_id'
+    )
     role = models.ForeignKey(
         to='dcim.InventoryItemRole',
         on_delete=models.PROTECT,

+ 10 - 5
netbox/dcim/tables/devices.py

@@ -780,6 +780,9 @@ class InventoryItemTable(DeviceComponentTable):
     manufacturer = tables.Column(
         linkify=True
     )
+    component = tables.Column(
+        linkify=True
+    )
     discovered = BooleanColumn()
     tags = TagColumn(
         url_name='dcim:inventoryitem_list'
@@ -790,9 +793,11 @@ class InventoryItemTable(DeviceComponentTable):
         model = InventoryItem
         fields = (
             'pk', 'id', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
-            'description', 'discovered', 'tags',
+            'component', 'description', 'discovered', 'tags',
+        )
+        default_columns = (
+            'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
         )
-        default_columns = ('pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag')
 
 
 class DeviceInventoryItemTable(InventoryItemTable):
@@ -810,11 +815,11 @@ class DeviceInventoryItemTable(InventoryItemTable):
     class Meta(BaseTable.Meta):
         model = InventoryItem
         fields = (
-            'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
-            'discovered', 'tags', 'actions',
+            'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component',
+            'description', 'discovered', 'tags', 'actions',
         )
         default_columns = (
-            'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'actions',
+            'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'actions',
         )
 
 

+ 16 - 3
netbox/dcim/tests/test_api.py

@@ -1632,9 +1632,16 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
         )
         InventoryItemRole.objects.bulk_create(roles)
 
-        InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer)
-        InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer)
-        InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer)
+        interfaces = (
+            Interface(device=device, name='Interface 1'),
+            Interface(device=device, name='Interface 2'),
+            Interface(device=device, name='Interface 3'),
+        )
+        Interface.objects.bulk_create(interfaces)
+
+        InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer, component=interfaces[0])
+        InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer, component=interfaces[1])
+        InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer, component=interfaces[2])
 
         cls.create_data = [
             {
@@ -1642,18 +1649,24 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
                 'name': 'Inventory Item 4',
                 'role': roles[1].pk,
                 'manufacturer': manufacturer.pk,
+                'component_type': 'dcim.interface',
+                'component_id': interfaces[0].pk,
             },
             {
                 'device': device.pk,
                 'name': 'Inventory Item 5',
                 'role': roles[1].pk,
                 'manufacturer': manufacturer.pk,
+                'component_type': 'dcim.interface',
+                'component_id': interfaces[1].pk,
             },
             {
                 'device': device.pk,
                 'name': 'Inventory Item 6',
                 'role': roles[1].pk,
                 'manufacturer': manufacturer.pk,
+                'component_type': 'dcim.interface',
+                'component_id': interfaces[2].pk,
             },
         ]
 

+ 13 - 3
netbox/dcim/tests/test_filtersets.py

@@ -3004,10 +3004,16 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         InventoryItemRole.objects.bulk_create(roles)
 
+        components = (
+            Interface.objects.create(device=devices[0], name='Interface 1'),
+            ConsolePort.objects.create(device=devices[1], name='Console Port 1'),
+            ConsoleServerPort.objects.create(device=devices[2], name='Console Server Port 1'),
+        )
+
         inventory_items = (
-            InventoryItem(device=devices[0], role=roles[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'),
-            InventoryItem(device=devices[1], role=roles[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'),
-            InventoryItem(device=devices[2], role=roles[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'),
+            InventoryItem(device=devices[0], role=roles[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First', component=components[0]),
+            InventoryItem(device=devices[1], role=roles[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second', component=components[1]),
+            InventoryItem(device=devices[2], role=roles[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third', component=components[2]),
         )
         for i in inventory_items:
             i.save()
@@ -3103,6 +3109,10 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'serial': 'abc'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
+    def test_component_type(self):
+        params = {'component_type': 'dcim.interface'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
 
 class InventoryItemRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = InventoryItemRole.objects.all()

+ 8 - 22
netbox/dcim/tests/test_forms.py

@@ -118,41 +118,27 @@ class DeviceTestCase(TestCase):
 
 class LabelTestCase(TestCase):
 
-    @classmethod
-    def setUpTestData(cls):
-        site = Site.objects.create(name='Site 2', slug='site-2')
-        manufacturer = Manufacturer.objects.create(name='Manufacturer 2', slug='manufacturer-2')
-        cls.device_type = DeviceType.objects.create(
-            manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=1
-        )
-        device_role = DeviceRole.objects.create(
-            name='Device Role 2', slug='device-role-2', color='ffff00'
-        )
-        cls.device = Device.objects.create(
-            name='Device 2', device_type=cls.device_type, device_role=device_role, site=site
-        )
-
     def test_interface_label_count_valid(self):
-        """Test that a `label` can be generated for each generated `name` from `name_pattern` on InterfaceCreateForm"""
+        """
+        Test that generating an equal number of names and labels passes form validation.
+        """
         interface_data = {
-            'device': self.device.pk,
             'name_pattern': 'eth[0-9]',
             'label_pattern': 'Interface[0-9]',
-            'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
         }
-        form = InterfaceCreateForm(interface_data)
+        form = ComponentCreateForm(interface_data)
 
         self.assertTrue(form.is_valid())
 
     def test_interface_label_count_mismatch(self):
-        """Test that a `label` cannot be generated for each generated `name` from `name_pattern` due to invalid `label_pattern` on InterfaceCreateForm"""
+        """
+        Check that attempting to generate a differing number of names and labels results in a validation error.
+        """
         bad_interface_data = {
-            'device': self.device.pk,
             'name_pattern': 'eth[0-9]',
             'label_pattern': 'Interface[0-1]',
-            'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
         }
-        form = InterfaceCreateForm(bad_interface_data)
+        form = ComponentCreateForm(bad_interface_data)
 
         self.assertFalse(form.is_valid())
         self.assertIn('label_pattern', form.errors)

+ 44 - 44
netbox/dcim/views.py

@@ -1054,7 +1054,6 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
 
 class ConsolePortTemplateCreateView(generic.ComponentCreateView):
     queryset = ConsolePortTemplate.objects.all()
-    form = forms.ConsolePortTemplateCreateForm
     model_form = forms.ConsolePortTemplateForm
 
 
@@ -1088,7 +1087,6 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
     queryset = ConsoleServerPortTemplate.objects.all()
-    form = forms.ConsoleServerPortTemplateCreateForm
     model_form = forms.ConsoleServerPortTemplateForm
 
 
@@ -1122,7 +1120,6 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class PowerPortTemplateCreateView(generic.ComponentCreateView):
     queryset = PowerPortTemplate.objects.all()
-    form = forms.PowerPortTemplateCreateForm
     model_form = forms.PowerPortTemplateForm
 
 
@@ -1156,7 +1153,6 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class PowerOutletTemplateCreateView(generic.ComponentCreateView):
     queryset = PowerOutletTemplate.objects.all()
-    form = forms.PowerOutletTemplateCreateForm
     model_form = forms.PowerOutletTemplateForm
 
 
@@ -1190,7 +1186,6 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class InterfaceTemplateCreateView(generic.ComponentCreateView):
     queryset = InterfaceTemplate.objects.all()
-    form = forms.InterfaceTemplateCreateForm
     model_form = forms.InterfaceTemplateForm
 
 
@@ -1227,6 +1222,14 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView):
     form = forms.FrontPortTemplateCreateForm
     model_form = forms.FrontPortTemplateForm
 
+    def initialize_forms(self, request):
+        form, model_form = super().initialize_forms(request)
+
+        model_form.fields.pop('rear_port')
+        model_form.fields.pop('rear_port_position')
+
+        return form, model_form
+
 
 class FrontPortTemplateEditView(generic.ObjectEditView):
     queryset = FrontPortTemplate.objects.all()
@@ -1258,7 +1261,6 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class RearPortTemplateCreateView(generic.ComponentCreateView):
     queryset = RearPortTemplate.objects.all()
-    form = forms.RearPortTemplateCreateForm
     model_form = forms.RearPortTemplateForm
 
 
@@ -1292,7 +1294,6 @@ class RearPortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class ModuleBayTemplateCreateView(generic.ComponentCreateView):
     queryset = ModuleBayTemplate.objects.all()
-    form = forms.ModuleBayTemplateCreateForm
     model_form = forms.ModuleBayTemplateForm
 
 
@@ -1326,7 +1327,6 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class DeviceBayTemplateCreateView(generic.ComponentCreateView):
     queryset = DeviceBayTemplate.objects.all()
-    form = forms.DeviceBayTemplateCreateForm
     model_form = forms.DeviceBayTemplateForm
 
 
@@ -1741,7 +1741,6 @@ class ConsolePortView(generic.ObjectView):
 
 class ConsolePortCreateView(generic.ComponentCreateView):
     queryset = ConsolePort.objects.all()
-    form = forms.ConsolePortCreateForm
     model_form = forms.ConsolePortForm
 
 
@@ -1800,7 +1799,6 @@ class ConsoleServerPortView(generic.ObjectView):
 
 class ConsoleServerPortCreateView(generic.ComponentCreateView):
     queryset = ConsoleServerPort.objects.all()
-    form = forms.ConsoleServerPortCreateForm
     model_form = forms.ConsoleServerPortForm
 
 
@@ -1859,7 +1857,6 @@ class PowerPortView(generic.ObjectView):
 
 class PowerPortCreateView(generic.ComponentCreateView):
     queryset = PowerPort.objects.all()
-    form = forms.PowerPortCreateForm
     model_form = forms.PowerPortForm
 
 
@@ -1918,7 +1915,6 @@ class PowerOutletView(generic.ObjectView):
 
 class PowerOutletCreateView(generic.ComponentCreateView):
     queryset = PowerOutlet.objects.all()
-    form = forms.PowerOutletCreateForm
     model_form = forms.PowerOutletForm
 
 
@@ -2012,35 +2008,35 @@ class InterfaceView(generic.ObjectView):
 
 class InterfaceCreateView(generic.ComponentCreateView):
     queryset = Interface.objects.all()
-    form = forms.InterfaceCreateForm
     model_form = forms.InterfaceForm
-    template_name = 'dcim/interface_create.html'
-
-    def post(self, request):
-        """
-        Override inherited post() method to handle request to assign newly created
-        interface objects (first object) to an IP Address object.
-        """
-        form = self.form(request.POST, initial=request.GET)
-        new_objs = self.validate_form(request, form)
-
-        if form.is_valid() and not form.errors:
-            if '_addanother' in request.POST:
-                return redirect(request.get_full_path())
-            elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \
-                    request.user.has_perm('ipam.add_ipaddress'):
-                first_obj = new_objs[0].pk
-                return redirect(
-                    f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}'
-                )
-            else:
-                return redirect(self.get_return_url(request))
-
-        return render(request, self.template_name, {
-            'obj_type': self.queryset.model._meta.verbose_name,
-            'form': form,
-            'return_url': self.get_return_url(request),
-        })
+    # template_name = 'dcim/interface_create.html'
+
+    # TODO: Figure out what to do with this
+    # def post(self, request):
+    #     """
+    #     Override inherited post() method to handle request to assign newly created
+    #     interface objects (first object) to an IP Address object.
+    #     """
+    #     form = self.form(request.POST, initial=request.GET)
+    #     new_objs = self.validate_form(request, form)
+    #
+    #     if form.is_valid() and not form.errors:
+    #         if '_addanother' in request.POST:
+    #             return redirect(request.get_full_path())
+    #         elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \
+    #                 request.user.has_perm('ipam.add_ipaddress'):
+    #             first_obj = new_objs[0].pk
+    #             return redirect(
+    #                 f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}'
+    #             )
+    #         else:
+    #             return redirect(self.get_return_url(request))
+    #
+    #     return render(request, self.template_name, {
+    #         'obj_type': self.queryset.model._meta.verbose_name,
+    #         'form': form,
+    #         'return_url': self.get_return_url(request),
+    #     })
 
 
 class InterfaceEditView(generic.ObjectEditView):
@@ -2101,6 +2097,14 @@ class FrontPortCreateView(generic.ComponentCreateView):
     form = forms.FrontPortCreateForm
     model_form = forms.FrontPortForm
 
+    def initialize_forms(self, request):
+        form, model_form = super().initialize_forms(request)
+
+        model_form.fields.pop('rear_port')
+        model_form.fields.pop('rear_port_position')
+
+        return form, model_form
+
 
 class FrontPortEditView(generic.ObjectEditView):
     queryset = FrontPort.objects.all()
@@ -2157,7 +2161,6 @@ class RearPortView(generic.ObjectView):
 
 class RearPortCreateView(generic.ComponentCreateView):
     queryset = RearPort.objects.all()
-    form = forms.RearPortCreateForm
     model_form = forms.RearPortForm
 
 
@@ -2216,7 +2219,6 @@ class ModuleBayView(generic.ObjectView):
 
 class ModuleBayCreateView(generic.ComponentCreateView):
     queryset = ModuleBay.objects.all()
-    form = forms.ModuleBayCreateForm
     model_form = forms.ModuleBayForm
 
 
@@ -2271,7 +2273,6 @@ class DeviceBayView(generic.ObjectView):
 
 class DeviceBayCreateView(generic.ComponentCreateView):
     queryset = DeviceBay.objects.all()
-    form = forms.DeviceBayCreateForm
     model_form = forms.DeviceBayForm
 
 
@@ -2397,7 +2398,6 @@ class InventoryItemEditView(generic.ObjectEditView):
 
 class InventoryItemCreateView(generic.ComponentCreateView):
     queryset = InventoryItem.objects.all()
-    form = forms.InventoryItemCreateForm
     model_form = forms.InventoryItemForm
 
 

+ 27 - 11
netbox/netbox/views/generic/object_views.py

@@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
 from django.db.models import ProtectedError
+from django.forms.widgets import HiddenInput
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.html import escape
@@ -14,6 +15,7 @@ from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django_tables2.export import TableExport
 
+from dcim.forms.object_create import ComponentCreateForm
 from extras.models import ExportTemplate
 from extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
@@ -674,33 +676,46 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 # Device/VirtualMachine components
 #
 
-# TODO: Replace with BulkCreateView
 class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
     """
     Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
     """
     queryset = None
-    form = None
+    form = ComponentCreateForm
     model_form = None
-    template_name = 'generic/object_edit.html'
+    template_name = 'dcim/component_create.html'
+    patterned_fields = ('name', 'label')
 
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'add')
 
-    def get(self, request):
+    def initialize_forms(self, request):
+        data = request.POST if request.method == 'POST' else None
+        initial_data = normalize_querydict(request.GET)
+
+        form = self.form(data=data, initial=request.GET)
+        model_form = self.model_form(data=data, initial=initial_data)
+
+        # These fields will be set from the pattern values
+        for field_name in self.patterned_fields:
+            model_form.fields[field_name].widget = HiddenInput()
+
+        return form, model_form
 
-        form = self.form(initial=request.GET)
+    def get(self, request):
+        form, model_form = self.initialize_forms(request)
 
         return render(request, self.template_name, {
-            'obj': self.queryset.model(),
+            'obj': self.queryset.model,
             'obj_type': self.queryset.model._meta.verbose_name,
-            'form': form,
+            'replication_form': form,
+            'form': model_form,
             'return_url': self.get_return_url(request),
         })
 
     def post(self, request):
-        logger = logging.getLogger('netbox.views.ComponentCreateView')
-        form = self.form(request.POST, initial=request.GET)
+        form, model_form = self.initialize_forms(request)
+
         self.validate_form(request, form)
 
         if form.is_valid() and not form.errors:
@@ -710,8 +725,10 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
                 return redirect(self.get_return_url(request))
 
         return render(request, self.template_name, {
+            'obj': self.queryset.model,
             'obj_type': self.queryset.model._meta.verbose_name,
-            'form': form,
+            'replication_form': form,
+            'form': model_form,
             'return_url': self.get_return_url(request),
         })
 
@@ -720,7 +737,6 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
         Validate form values and set errors on the form object as they are detected. If
         no errors are found, signal success messages.
         """
-
         logger = logging.getLogger('netbox.views.ComponentCreateView')
         if form.is_valid():
             new_components = []

+ 7 - 0
netbox/templates/dcim/component_create.html

@@ -0,0 +1,7 @@
+{% extends 'generic/object_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+  {% render_form replication_form %}
+  {{ block.super }}
+{% endblock form %}

+ 80 - 79
netbox/templates/dcim/consoleport.html

@@ -58,91 +58,92 @@
                 </h5>
                 <div class="card-body">
                     {% if object.mark_connected %}
-                    <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
+                        <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
                     {% elif object.cable %}
-                    <table class="table table-hover attr-table">
-                        <tr>
-                            <th scope="row">Cable</th>
-                            <td>
-                                <a href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a>
-                                <a href="{% url 'dcim:consoleport_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
-                                    <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
-                                </a>
-                            </td>
-                        </tr>
-                        {% if object.connected_endpoint %}
-                            <tr>
-                                <th scope="row">Device</th>
-                                <td>
-                                    <a href="{{ object.connected_endpoint.device.get_absolute_url }}">{{ object.connected_endpoint.device }}</a>
-                                </td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Name</th>
-                                <td>
-                                    <a href="{{ object.connected_endpoint.get_absolute_url }}">{{ object.connected_endpoint.name }}</a>
-                                </td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Type</th>
-                                <td>{{ object.connected_endpoint.get_type_display|placeholder }}</td>
-                            </tr>
+                        <table class="table table-hover attr-table">
                             <tr>
-                                <th scope="row">Description</th>
-                                <td>{{ object.connected_endpoint.description|placeholder }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Path Status</th>
+                                <th scope="row">Cable</th>
                                 <td>
-                                    {% if object.path.is_active %}
-                                        <span class="badge bg-success">Reachable</span>
-                                    {% else %}
-                                        <span class="badge bg-danger">Not Reachable</span>
-                                    {% endif %}
+                                    <a href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a>
+                                    <a href="{% url 'dcim:consoleport_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
+                                        <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
+                                    </a>
                                 </td>
                             </tr>
-                        {% endif %}
-                    </table>
-                {% else %}
-                    <div class="text-muted">
-                        Not Connected
-                        {% if perms.dcim.add_cable %}
-                            <div class="dropdown float-end">
-                                <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
-                                    <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
-                                </button>
-                                <ul class="dropdown-menu dropdown-menu-end">
-                                    <li>
-                                        <a
-                                            class="dropdown-item"
-                                            href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='console-server-port' %}?return_url={{ object.get_absolute_url }}"
-                                        >
-                                            Console Server Port
-                                        </a>
-                                    </li>
-                                    <li>
-                                        <a
-                                            class="dropdown-item"
-                                            href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}"
-                                        >
-                                            Front Port
-                                        </a>
-                                    </li>
-                                    <li>
-                                        <a
-                                            class="dropdown-item"
-                                            href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}"
-                                        >
-                                            Rear Port
-                                        </a>
-                                    </li>
-                                </ul>
-                            </div>
-                        {% endif %}
-                    </div>
-                {% endif %}
-            </div>
+                            {% if object.connected_endpoint %}
+                                <tr>
+                                    <th scope="row">Device</th>
+                                    <td>
+                                        <a href="{{ object.connected_endpoint.device.get_absolute_url }}">{{ object.connected_endpoint.device }}</a>
+                                    </td>
+                                </tr>
+                                <tr>
+                                    <th scope="row">Name</th>
+                                    <td>
+                                        <a href="{{ object.connected_endpoint.get_absolute_url }}">{{ object.connected_endpoint.name }}</a>
+                                    </td>
+                                </tr>
+                                <tr>
+                                    <th scope="row">Type</th>
+                                    <td>{{ object.connected_endpoint.get_type_display|placeholder }}</td>
+                                </tr>
+                                <tr>
+                                    <th scope="row">Description</th>
+                                    <td>{{ object.connected_endpoint.description|placeholder }}</td>
+                                </tr>
+                                <tr>
+                                    <th scope="row">Path Status</th>
+                                    <td>
+                                        {% if object.path.is_active %}
+                                            <span class="badge bg-success">Reachable</span>
+                                        {% else %}
+                                            <span class="badge bg-danger">Not Reachable</span>
+                                        {% endif %}
+                                    </td>
+                                </tr>
+                            {% endif %}
+                        </table>
+                    {% else %}
+                        <div class="text-muted">
+                            Not Connected
+                            {% if perms.dcim.add_cable %}
+                                <div class="dropdown float-end">
+                                    <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+                                        <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
+                                    </button>
+                                    <ul class="dropdown-menu dropdown-menu-end">
+                                        <li>
+                                            <a
+                                                class="dropdown-item"
+                                                href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='console-server-port' %}?return_url={{ object.get_absolute_url }}"
+                                            >
+                                                Console Server Port
+                                            </a>
+                                        </li>
+                                        <li>
+                                            <a
+                                                class="dropdown-item"
+                                                href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}"
+                                            >
+                                                Front Port
+                                            </a>
+                                        </li>
+                                        <li>
+                                            <a
+                                                class="dropdown-item"
+                                                href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}"
+                                            >
+                                                Rear Port
+                                            </a>
+                                        </li>
+                                    </ul>
+                                </div>
+                            {% endif %}
+                        </div>
+                    {% endif %}
+                </div>
             </div>
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_right_page object %}
         </div>
     </div>

+ 1 - 0
netbox/templates/dcim/consoleserverport.html

@@ -143,6 +143,7 @@
                 {% endif %}
                 </div>
             </div>
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_right_page object %}
         </div>
     </div>

+ 1 - 0
netbox/templates/dcim/frontport.html

@@ -129,6 +129,7 @@
                 {% endif %}
                 </div>
             </div>
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_right_page object %}
         </div>
     </div>

+ 59 - 0
netbox/templates/dcim/inc/panels/inventory_items.html

@@ -0,0 +1,59 @@
+{% load helpers %}
+
+<div class="card">
+  <h5 class="card-header">Inventory Items</h5>
+  <div class="card-body">
+    <table class="table table-hover table-headings">
+      <thead>
+        <tr>
+          <th>Name</th>
+          <th>Label</th>
+          <th>Role</th>
+          <th></th>
+        </tr>
+      </thead>
+      <tbody>
+        {% for item in object.inventory_items.all %}
+          <tr>
+            <td>
+              <a href="{{ item.get_absolute_url }}">{{ item.name }}</a>
+            </td>
+            <td>
+              {{ item.label|placeholder }}
+            </td>
+            <td>
+              {% if item.role %}
+                <a href="{{ item.role.get_absolute_url }}">{{ item.role }}</a>
+              {% else %}
+                <span class="text-muted">&mdash;</span>
+              {% endif %}
+            </td>
+            <td class="text-end noprint">
+              {% if perms.dcim.change_inventoryitem %}
+                <a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning btn-sm lh-1" title="Edit">
+                  <i class="mdi mdi-pencil" aria-hidden="true"></i>
+                </a>
+              {% endif %}
+              {% if perms.ipam.delete_inventoryitem %}
+                <a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger btn-sm lh-1" title="Delete">
+                  <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
+                </a>
+              {% endif %}
+            </td>
+          </tr>
+        {% empty %}
+          <tr>
+            <td colspan="5" class="text-muted">None</td>
+          </tr>
+        {% endfor %}
+      </tbody>
+    </table>
+  </div>
+  <div class="card-footer text-end noprint">
+    {% if perms.dcim.add_inventoryitem %}
+      <a href="{% url 'dcim:inventoryitem_add' %}?device={{ object.device.pk }}&component_type={{ object|content_type_id }}&component_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-primary">
+        <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Inventory Item
+      </a>
+    {% endif %}
+  </div>
+</div>

+ 1 - 0
netbox/templates/dcim/interface.html

@@ -448,6 +448,7 @@
                 </div>
             {% endif %}
             {% include 'ipam/inc/panels/fhrp_groups.html' %}
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_right_page object %}
         </div>
     </div>

+ 0 - 16
netbox/templates/dcim/interface_create.html

@@ -1,16 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-
-{% block buttons %}
-  <a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
-  {% if component_type == 'interface' and perms.ipam.add_ipaddress %}
-    <button type="submit" name="_assignip" class="btn btn-outline-success">
-      Create & Assign IP Address
-    </button>
-  {% endif %}
-  <button type="submit" name="_addanother" class="btn btn-outline-primary">
-    Create & Add Another
-  </button>
-  <button type="submit" name="_create" class="btn btn-primary">
-    Create
-  </button>
-{% endblock %}

+ 10 - 0
netbox/templates/dcim/inventoryitem.html

@@ -50,6 +50,16 @@
                                 {% endif %}
                             </td>
                         </tr>
+                        <tr>
+                            <th scope="row">Component</th>
+                            <td>
+                                {% if object.component %}
+                                    <a href="{{ object.component.get_absolute_url }}">{{ object.component }}</a>
+                                {% else %}
+                                    <span class="text-muted">&mdash;</span>
+                                {% endif %}
+                            </td>
+                        </tr>
                         <tr>
                             <th scope="row">Manufacturer</th>
                             <td>

+ 1 - 0
netbox/templates/dcim/poweroutlet.html

@@ -121,6 +121,7 @@
                 {% endif %}
                 </div>
             </div>
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_right_page object %}
         </div>
     </div>

+ 1 - 0
netbox/templates/dcim/powerport.html

@@ -131,6 +131,7 @@
                 {% endif %}
                 </div>
             </div>
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_right_page object %}
         </div>
     </div>

+ 1 - 0
netbox/templates/dcim/rearport.html

@@ -117,6 +117,7 @@
                 {% endif %}
                 </div>
             </div>
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_right_page object %}
         </div>
     </div>

+ 15 - 9
netbox/templates/generic/object_edit.html

@@ -29,29 +29,35 @@
         </div>
       {% endif %}
 
-      <form action="" method="post" enctype="multipart/form-data" class="form-object-edit">
+      <form action="" method="post" enctype="multipart/form-data" class="form-object-edit mt-5">
         {% csrf_token %}
-        {% for field in form.hidden_fields %}
-          {{ field }}
-        {% endfor %}
 
         {% block form %}
           {% if form.Meta.fieldsets %}
 
+            {# Render hidden fields #}
+            {% for field in form.hidden_fields %}
+              {{ field }}
+            {% endfor %}
+
             {# Render grouped fields according to Form #}
             {% for group, fields in form.Meta.fieldsets %}
-              <div class="field-group my-5">
+              <div class="field-group mb-5">
                 <div class="row mb-2">
                   <h5 class="offset-sm-3">{{ group }}</h5>
                 </div>
                 {% for name in fields %}
-                    {% render_field form|getfield:name %}
+                  {% with field=form|getfield:name %}
+                    {% if not field.field.widget.is_hidden %}
+                      {% render_field field %}
+                    {% endif %}
+                  {% endwith %}
                 {% endfor %}
               </div>
             {% endfor %}
 
             {% if form.custom_fields %}
-              <div class="field-group my-5">
+              <div class="field-group mb-5">
                 <div class="row mb-2">
                   <h5 class="offset-sm-3">Custom Fields</h5>
                 </div>
@@ -60,7 +66,7 @@
             {% endif %}
 
             {% if form.comments %}
-              <div class="field-group my-5">
+              <div class="field-group mb-5">
                 <h5 class="text-center">Comments</h5>
                 {% render_field form.comments %}
               </div>
@@ -68,7 +74,7 @@
 
           {% else %}
             {# Render all fields in a single group #}
-            <div class="field-group my-5">
+            <div class="field-group mb-5">
               {% block form_fields %}{% render_form form %}{% endblock %}
             </div>
           {% endif %}

+ 1 - 5
netbox/utilities/forms/fields.py

@@ -346,11 +346,7 @@ class ExpandableNameField(forms.CharField):
         if not self.help_text:
             self.help_text = """
                 Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
-                are not supported. Examples:
-                <ul>
-                    <li><code>[ge,xe]-0/0/[0-9]</code></li>
-                    <li><code>e[0-3][a-d,f]</code></li>
-                </ul>
+                are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>
                 """
 
     def to_python(self, value):

+ 10 - 14
netbox/virtualization/forms/models.py

@@ -275,12 +275,18 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
     parent = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         required=False,
-        label='Parent interface'
+        label='Parent interface',
+        query_params={
+            'virtual_machine_id': '$virtual_machine',
+        }
     )
     bridge = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         required=False,
-        label='Bridged interface'
+        label='Bridged interface',
+        query_params={
+            'virtual_machine_id': '$virtual_machine',
+        }
     )
     vlan_group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
@@ -293,6 +299,7 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         label='Untagged VLAN',
         query_params={
             'group_id': '$vlan_group',
+            'available_on_virtualmachine': '$virtual_machine',
         }
     )
     tagged_vlans = DynamicModelMultipleChoiceField(
@@ -301,6 +308,7 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         label='Tagged VLANs',
         query_params={
             'group_id': '$vlan_group',
+            'available_on_virtualmachine': '$virtual_machine',
         }
     )
     tags = DynamicModelMultipleChoiceField(
@@ -324,15 +332,3 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         help_texts = {
             'mode': INTERFACE_MODE_HELP_TEXT,
         }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
-
-        # Restrict parent interface assignment by VM
-        self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id)
-        self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id)
-
-        # Limit VLAN choices by virtual machine
-        self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
-        self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)

+ 2 - 70
netbox/virtualization/forms/object_create.py

@@ -1,81 +1,13 @@
 from django import forms
 
-from dcim.choices import InterfaceModeChoices
-from dcim.forms.common import InterfaceCommonForm
-from extras.forms import CustomFieldsMixin
-from extras.models import Tag
-from ipam.models import VLAN
-from utilities.forms import (
-    add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
-    StaticSelect,
-)
-from virtualization.models import VMInterface, VirtualMachine
+from utilities.forms import BootstrapMixin, ExpandableNameField
 
 __all__ = (
     'VMInterfaceCreateForm',
 )
 
 
-class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonForm):
-    model = VMInterface
-    virtual_machine = DynamicModelChoiceField(
-        queryset=VirtualMachine.objects.all()
-    )
+class VMInterfaceCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(
         label='Name'
     )
-    enabled = forms.BooleanField(
-        required=False,
-        initial=True
-    )
-    parent = DynamicModelChoiceField(
-        queryset=VMInterface.objects.all(),
-        required=False,
-        query_params={
-            'virtual_machine_id': '$virtual_machine',
-        }
-    )
-    bridge = DynamicModelChoiceField(
-        queryset=VMInterface.objects.all(),
-        required=False,
-        query_params={
-            'virtual_machine_id': '$virtual_machine',
-        }
-    )
-    mac_address = forms.CharField(
-        required=False,
-        label='MAC Address'
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
-    mode = forms.ChoiceField(
-        choices=add_blank_choice(InterfaceModeChoices),
-        required=False,
-        widget=StaticSelect(),
-    )
-    untagged_vlan = DynamicModelChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False
-    )
-    tagged_vlans = DynamicModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False
-    )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False
-    )
-    field_order = (
-        'virtual_machine', 'name_pattern', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode',
-        'untagged_vlan', 'tagged_vlans', 'tags'
-    )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
-
-        # Limit VLAN choices by virtual machine
-        self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
-        self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)

+ 1 - 1
netbox/virtualization/views.py

@@ -447,11 +447,11 @@ class VMInterfaceView(generic.ObjectView):
         }
 
 
-# TODO: This should not use ComponentCreateView
 class VMInterfaceCreateView(generic.ComponentCreateView):
     queryset = VMInterface.objects.all()
     form = forms.VMInterfaceCreateForm
     model_form = forms.VMInterfaceForm
+    patterned_fields = ('name',)
 
 
 class VMInterfaceEditView(generic.ObjectEditView):