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

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
 * [#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
 * [#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
 * [#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
 * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group
 
 
 ### Other Changes
 ### Other Changes
@@ -76,7 +77,8 @@ FIELD_CHOICES = {
 * dcim.Interface
 * dcim.Interface
     * Added `module` field
     * Added `module` field
 * dcim.InventoryItem
 * dcim.InventoryItem
-    * Added `role` field
+    * Added `component_type`, `component_id`, and `role` fields
+    * Added read-only `component` field
 * dcim.PowerPort
 * dcim.PowerPort
     * Added `module` field
     * Added `module` field
 * dcim.PowerOutlet
 * 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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
     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)
     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)
     _depth = serializers.IntegerField(source='level', read_only=True)
 
 
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
         fields = [
         fields = [
             'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial',
             '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
 # 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_VOLTAGE_DEFAULT = 120
-
 POWERFEED_AMPERAGE_DEFAULT = 20
 POWERFEED_AMPERAGE_DEFAULT = 20
-
 POWERFEED_MAX_UTILIZATION_DEFAULT = 80  # Percentage
 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
 # Cabling and connections
 #
 #

+ 2 - 0
netbox/dcim/filtersets.py

@@ -1294,6 +1294,8 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
     )
     )
+    component_type = ContentTypeFilter()
+    component_id = MultiValueNumberFilter()
     serial = django_filters.CharFilter(
     serial = django_filters.CharFilter(
         lookup_expr='iexact'
         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.forms import CustomFieldsMixin
 from extras.models import Tag
 from extras.models import Tag
 from utilities.forms import DynamicModelMultipleChoiceField, form_from_model
 from utilities.forms import DynamicModelMultipleChoiceField, form_from_model
-from .object_create import ComponentForm
+from .object_create import ComponentCreateForm
 
 
 __all__ = (
 __all__ = (
     'ConsolePortBulkCreateForm',
     'ConsolePortBulkCreateForm',
@@ -24,7 +24,7 @@ __all__ = (
 # Device components
 # Device components
 #
 #
 
 
-class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentForm):
+class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         widget=forms.MultipleHiddenInput()
         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 ipam.models import IPAddress, VLAN, VLANGroup, ASN
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from utilities.forms import (
 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,
     SlugField, StaticSelect,
 )
 )
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
@@ -957,6 +957,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
             'module_type': forms.HiddenInput(),
             'module_type': forms.HiddenInput(),
+            'type': StaticSelect,
         }
         }
 
 
 
 
@@ -969,6 +970,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
             'module_type': forms.HiddenInput(),
             'module_type': forms.HiddenInput(),
+            'type': StaticSelect,
         }
         }
 
 
 
 
@@ -981,10 +983,19 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
             'module_type': forms.HiddenInput(),
             'module_type': forms.HiddenInput(),
+            'type': StaticSelect(),
         }
         }
 
 
 
 
 class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
 class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
+    power_port = DynamicModelChoiceField(
+        queryset=PowerPortTemplate.objects.all(),
+        required=False,
+        query_params={
+            'devicetype_id': '$device_type',
+        }
+    )
+
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
         fields = [
         fields = [
@@ -993,18 +1004,10 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
             'module_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 InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
@@ -1020,6 +1023,14 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
 
 
 
 
 class FrontPortTemplateForm(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:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         fields = [
@@ -1029,19 +1040,9 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
             'module_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 RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
@@ -1095,6 +1096,8 @@ class ConsolePortForm(CustomFieldModelForm):
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
+            'type': StaticSelect(),
+            'speed': StaticSelect(),
         }
         }
 
 
 
 
@@ -1111,6 +1114,8 @@ class ConsoleServerPortForm(CustomFieldModelForm):
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
+            'type': StaticSelect(),
+            'speed': StaticSelect(),
         }
         }
 
 
 
 
@@ -1128,13 +1133,17 @@ class PowerPortForm(CustomFieldModelForm):
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
+            'type': StaticSelect(),
         }
         }
 
 
 
 
 class PowerOutletForm(CustomFieldModelForm):
 class PowerOutletForm(CustomFieldModelForm):
-    power_port = forms.ModelChoiceField(
+    power_port = DynamicModelChoiceField(
         queryset=PowerPort.objects.all(),
         queryset=PowerPort.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'device_id': '$device',
+        }
     )
     )
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
@@ -1148,34 +1157,34 @@ class PowerOutletForm(CustomFieldModelForm):
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             '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):
 class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False,
         required=False,
-        label='Parent interface'
+        label='Parent interface',
+        query_params={
+            'device_id': '$device',
+        }
     )
     )
     bridge = DynamicModelChoiceField(
     bridge = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False,
         required=False,
-        label='Bridged interface'
+        label='Bridged interface',
+        query_params={
+            'device_id': '$device',
+        }
     )
     )
     lag = DynamicModelChoiceField(
     lag = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False,
         required=False,
         label='LAG interface',
         label='LAG interface',
         query_params={
         query_params={
+            'device_id': '$device',
             'type': 'lag',
             'type': 'lag',
         }
         }
     )
     )
@@ -1203,6 +1212,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         label='Untagged VLAN',
         label='Untagged VLAN',
         query_params={
         query_params={
             'group_id': '$vlan_group',
             'group_id': '$vlan_group',
+            'available_on_device': '$device',
         }
         }
     )
     )
     tagged_vlans = DynamicModelMultipleChoiceField(
     tagged_vlans = DynamicModelMultipleChoiceField(
@@ -1211,6 +1221,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         label='Tagged VLANs',
         label='Tagged VLANs',
         query_params={
         query_params={
             'group_id': '$vlan_group',
             'group_id': '$vlan_group',
+            'available_on_device': '$device',
         }
         }
     )
     )
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
@@ -1225,6 +1236,17 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
             'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
             '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',
             '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 = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
             'type': StaticSelect(),
             'type': StaticSelect(),
@@ -1241,26 +1263,14 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
             'rf_channel_width': "Populated by selected channel (if set)",
             '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):
 class FrontPortForm(CustomFieldModelForm):
+    rear_port = DynamicModelChoiceField(
+        queryset=RearPort.objects.all(),
+        query_params={
+            'device_id': '$device',
+        }
+    )
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
@@ -1275,18 +1285,8 @@ class FrontPortForm(CustomFieldModelForm):
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
             'type': StaticSelect(),
             '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):
 class RearPortForm(CustomFieldModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
@@ -1358,9 +1358,6 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
 
 
 
 
 class InventoryItemForm(CustomFieldModelForm):
 class InventoryItemForm(CustomFieldModelForm):
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.all()
-    )
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=InventoryItem.objects.all(),
         queryset=InventoryItem.objects.all(),
         required=False,
         required=False,
@@ -1376,6 +1373,15 @@ class InventoryItemForm(CustomFieldModelForm):
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False
         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(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
@@ -1385,8 +1391,16 @@ class InventoryItemForm(CustomFieldModelForm):
         model = InventoryItem
         model = InventoryItem
         fields = [
         fields = [
             'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
             '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 django import forms
 
 
-from dcim.choices import *
-from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
-from extras.forms import CustomFieldModelForm, CustomFieldsMixin
+from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
-from ipam.models import VLAN
 from utilities.forms import (
 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__ = (
 __all__ = (
-    'ConsolePortCreateForm',
-    'ConsolePortTemplateCreateForm',
-    'ConsoleServerPortCreateForm',
-    'ConsoleServerPortTemplateCreateForm',
-    'DeviceBayCreateForm',
-    'DeviceBayTemplateCreateForm',
+    'ComponentCreateForm',
     'FrontPortCreateForm',
     'FrontPortCreateForm',
     'FrontPortTemplateCreateForm',
     'FrontPortTemplateCreateForm',
-    'InterfaceCreateForm',
-    'InterfaceTemplateCreateForm',
-    'InventoryItemCreateForm',
-    'ModuleBayCreateForm',
-    'ModuleBayTemplateCreateForm',
-    'PowerOutletCreateForm',
-    'PowerOutletTemplateCreateForm',
-    'PowerPortCreateForm',
-    'PowerPortTemplateCreateForm',
-    'RearPortCreateForm',
-    'RearPortTemplateCreateForm',
     'VirtualChassisCreateForm',
     '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
     Subclass this form when facilitating the creation of one or more device component or component templates based on
     a name pattern.
     a name pattern.
@@ -65,215 +43,14 @@ class ComponentForm(BootstrapMixin, forms.Form):
                 }, code='label_pattern_mismatch')
                 }, 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(
     rear_port_set = forms.MultipleChoiceField(
         choices=[],
         choices=[],
         label='Rear ports',
         label='Rear ports',
         help_text='Select one rear port assignment for each front port being created.',
         help_text='Select one rear port assignment for each front port being created.',
     )
     )
     field_order = (
     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):
     def __init__(self, *args, **kwargs):
@@ -300,18 +77,6 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
                     )
                     )
         self.fields['rear_port_set'].choices = choices
         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):
     def get_iterative_data(self, iteration):
 
 
         # Assign rear port and position from selected set
         # 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):
 class FrontPortCreateForm(ComponentCreateForm):
-    model = FrontPort
-    type = forms.ChoiceField(
-        choices=PortTypeChoices,
-        widget=StaticSelect(),
-    )
-    color = ColorField(
-        required=False
-    )
     rear_port_set = forms.MultipleChoiceField(
     rear_port_set = forms.MultipleChoiceField(
         choices=[],
         choices=[],
         label='Rear ports',
         label='Rear ports',
         help_text='Select one rear port assignment for each front port being created.',
         help_text='Select one rear port assignment for each front port being created.',
     )
     )
     field_order = (
     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):
     def __init__(self, *args, **kwargs):
@@ -596,18 +123,6 @@ class FrontPortCreateForm(ComponentCreateForm):
                     )
                     )
         self.fields['rear_port_set'].choices = choices
         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):
     def get_iterative_data(self, iteration):
 
 
         # Assign rear port and position from selected set
         # 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,
         required=False,
         query_params={
         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,
         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,
         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,
         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,
         blank=True,
         null=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:
     class Meta:
         abstract = True
         abstract = True
@@ -994,6 +1000,22 @@ class InventoryItem(MPTTModel, ComponentModel):
         null=True,
         null=True,
         db_index=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(
     role = models.ForeignKey(
         to='dcim.InventoryItemRole',
         to='dcim.InventoryItemRole',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,

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

@@ -780,6 +780,9 @@ class InventoryItemTable(DeviceComponentTable):
     manufacturer = tables.Column(
     manufacturer = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    component = tables.Column(
+        linkify=True
+    )
     discovered = BooleanColumn()
     discovered = BooleanColumn()
     tags = TagColumn(
     tags = TagColumn(
         url_name='dcim:inventoryitem_list'
         url_name='dcim:inventoryitem_list'
@@ -790,9 +793,11 @@ class InventoryItemTable(DeviceComponentTable):
         model = InventoryItem
         model = InventoryItem
         fields = (
         fields = (
             'pk', 'id', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
             '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):
 class DeviceInventoryItemTable(InventoryItemTable):
@@ -810,11 +815,11 @@ class DeviceInventoryItemTable(InventoryItemTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = InventoryItem
         model = InventoryItem
         fields = (
         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 = (
         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)
         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 = [
         cls.create_data = [
             {
             {
@@ -1642,18 +1649,24 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
                 'name': 'Inventory Item 4',
                 'name': 'Inventory Item 4',
                 'role': roles[1].pk,
                 'role': roles[1].pk,
                 'manufacturer': manufacturer.pk,
                 'manufacturer': manufacturer.pk,
+                'component_type': 'dcim.interface',
+                'component_id': interfaces[0].pk,
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Inventory Item 5',
                 'name': 'Inventory Item 5',
                 'role': roles[1].pk,
                 'role': roles[1].pk,
                 'manufacturer': manufacturer.pk,
                 'manufacturer': manufacturer.pk,
+                'component_type': 'dcim.interface',
+                'component_id': interfaces[1].pk,
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Inventory Item 6',
                 'name': 'Inventory Item 6',
                 'role': roles[1].pk,
                 'role': roles[1].pk,
                 'manufacturer': manufacturer.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)
         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 = (
         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:
         for i in inventory_items:
             i.save()
             i.save()
@@ -3103,6 +3109,10 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'serial': 'abc'}
         params = {'serial': 'abc'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         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):
 class InventoryItemRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = InventoryItemRole.objects.all()
     queryset = InventoryItemRole.objects.all()

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

@@ -118,41 +118,27 @@ class DeviceTestCase(TestCase):
 
 
 class LabelTestCase(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):
     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 = {
         interface_data = {
-            'device': self.device.pk,
             'name_pattern': 'eth[0-9]',
             'name_pattern': 'eth[0-9]',
             'label_pattern': 'Interface[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())
         self.assertTrue(form.is_valid())
 
 
     def test_interface_label_count_mismatch(self):
     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 = {
         bad_interface_data = {
-            'device': self.device.pk,
             'name_pattern': 'eth[0-9]',
             'name_pattern': 'eth[0-9]',
             'label_pattern': 'Interface[0-1]',
             '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.assertFalse(form.is_valid())
         self.assertIn('label_pattern', form.errors)
         self.assertIn('label_pattern', form.errors)

+ 44 - 44
netbox/dcim/views.py

@@ -1054,7 +1054,6 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
 
 
 class ConsolePortTemplateCreateView(generic.ComponentCreateView):
 class ConsolePortTemplateCreateView(generic.ComponentCreateView):
     queryset = ConsolePortTemplate.objects.all()
     queryset = ConsolePortTemplate.objects.all()
-    form = forms.ConsolePortTemplateCreateForm
     model_form = forms.ConsolePortTemplateForm
     model_form = forms.ConsolePortTemplateForm
 
 
 
 
@@ -1088,7 +1087,6 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 
 class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
 class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
     queryset = ConsoleServerPortTemplate.objects.all()
     queryset = ConsoleServerPortTemplate.objects.all()
-    form = forms.ConsoleServerPortTemplateCreateForm
     model_form = forms.ConsoleServerPortTemplateForm
     model_form = forms.ConsoleServerPortTemplateForm
 
 
 
 
@@ -1122,7 +1120,6 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 
 class PowerPortTemplateCreateView(generic.ComponentCreateView):
 class PowerPortTemplateCreateView(generic.ComponentCreateView):
     queryset = PowerPortTemplate.objects.all()
     queryset = PowerPortTemplate.objects.all()
-    form = forms.PowerPortTemplateCreateForm
     model_form = forms.PowerPortTemplateForm
     model_form = forms.PowerPortTemplateForm
 
 
 
 
@@ -1156,7 +1153,6 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 
 class PowerOutletTemplateCreateView(generic.ComponentCreateView):
 class PowerOutletTemplateCreateView(generic.ComponentCreateView):
     queryset = PowerOutletTemplate.objects.all()
     queryset = PowerOutletTemplate.objects.all()
-    form = forms.PowerOutletTemplateCreateForm
     model_form = forms.PowerOutletTemplateForm
     model_form = forms.PowerOutletTemplateForm
 
 
 
 
@@ -1190,7 +1186,6 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
 
 
 class InterfaceTemplateCreateView(generic.ComponentCreateView):
 class InterfaceTemplateCreateView(generic.ComponentCreateView):
     queryset = InterfaceTemplate.objects.all()
     queryset = InterfaceTemplate.objects.all()
-    form = forms.InterfaceTemplateCreateForm
     model_form = forms.InterfaceTemplateForm
     model_form = forms.InterfaceTemplateForm
 
 
 
 
@@ -1227,6 +1222,14 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView):
     form = forms.FrontPortTemplateCreateForm
     form = forms.FrontPortTemplateCreateForm
     model_form = forms.FrontPortTemplateForm
     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):
 class FrontPortTemplateEditView(generic.ObjectEditView):
     queryset = FrontPortTemplate.objects.all()
     queryset = FrontPortTemplate.objects.all()
@@ -1258,7 +1261,6 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 
 class RearPortTemplateCreateView(generic.ComponentCreateView):
 class RearPortTemplateCreateView(generic.ComponentCreateView):
     queryset = RearPortTemplate.objects.all()
     queryset = RearPortTemplate.objects.all()
-    form = forms.RearPortTemplateCreateForm
     model_form = forms.RearPortTemplateForm
     model_form = forms.RearPortTemplateForm
 
 
 
 
@@ -1292,7 +1294,6 @@ class RearPortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 
 class ModuleBayTemplateCreateView(generic.ComponentCreateView):
 class ModuleBayTemplateCreateView(generic.ComponentCreateView):
     queryset = ModuleBayTemplate.objects.all()
     queryset = ModuleBayTemplate.objects.all()
-    form = forms.ModuleBayTemplateCreateForm
     model_form = forms.ModuleBayTemplateForm
     model_form = forms.ModuleBayTemplateForm
 
 
 
 
@@ -1326,7 +1327,6 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView):
 
 
 class DeviceBayTemplateCreateView(generic.ComponentCreateView):
 class DeviceBayTemplateCreateView(generic.ComponentCreateView):
     queryset = DeviceBayTemplate.objects.all()
     queryset = DeviceBayTemplate.objects.all()
-    form = forms.DeviceBayTemplateCreateForm
     model_form = forms.DeviceBayTemplateForm
     model_form = forms.DeviceBayTemplateForm
 
 
 
 
@@ -1741,7 +1741,6 @@ class ConsolePortView(generic.ObjectView):
 
 
 class ConsolePortCreateView(generic.ComponentCreateView):
 class ConsolePortCreateView(generic.ComponentCreateView):
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
-    form = forms.ConsolePortCreateForm
     model_form = forms.ConsolePortForm
     model_form = forms.ConsolePortForm
 
 
 
 
@@ -1800,7 +1799,6 @@ class ConsoleServerPortView(generic.ObjectView):
 
 
 class ConsoleServerPortCreateView(generic.ComponentCreateView):
 class ConsoleServerPortCreateView(generic.ComponentCreateView):
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
-    form = forms.ConsoleServerPortCreateForm
     model_form = forms.ConsoleServerPortForm
     model_form = forms.ConsoleServerPortForm
 
 
 
 
@@ -1859,7 +1857,6 @@ class PowerPortView(generic.ObjectView):
 
 
 class PowerPortCreateView(generic.ComponentCreateView):
 class PowerPortCreateView(generic.ComponentCreateView):
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
-    form = forms.PowerPortCreateForm
     model_form = forms.PowerPortForm
     model_form = forms.PowerPortForm
 
 
 
 
@@ -1918,7 +1915,6 @@ class PowerOutletView(generic.ObjectView):
 
 
 class PowerOutletCreateView(generic.ComponentCreateView):
 class PowerOutletCreateView(generic.ComponentCreateView):
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
-    form = forms.PowerOutletCreateForm
     model_form = forms.PowerOutletForm
     model_form = forms.PowerOutletForm
 
 
 
 
@@ -2012,35 +2008,35 @@ class InterfaceView(generic.ObjectView):
 
 
 class InterfaceCreateView(generic.ComponentCreateView):
 class InterfaceCreateView(generic.ComponentCreateView):
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    form = forms.InterfaceCreateForm
     model_form = forms.InterfaceForm
     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):
 class InterfaceEditView(generic.ObjectEditView):
@@ -2101,6 +2097,14 @@ class FrontPortCreateView(generic.ComponentCreateView):
     form = forms.FrontPortCreateForm
     form = forms.FrontPortCreateForm
     model_form = forms.FrontPortForm
     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):
 class FrontPortEditView(generic.ObjectEditView):
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
@@ -2157,7 +2161,6 @@ class RearPortView(generic.ObjectView):
 
 
 class RearPortCreateView(generic.ComponentCreateView):
 class RearPortCreateView(generic.ComponentCreateView):
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
-    form = forms.RearPortCreateForm
     model_form = forms.RearPortForm
     model_form = forms.RearPortForm
 
 
 
 
@@ -2216,7 +2219,6 @@ class ModuleBayView(generic.ObjectView):
 
 
 class ModuleBayCreateView(generic.ComponentCreateView):
 class ModuleBayCreateView(generic.ComponentCreateView):
     queryset = ModuleBay.objects.all()
     queryset = ModuleBay.objects.all()
-    form = forms.ModuleBayCreateForm
     model_form = forms.ModuleBayForm
     model_form = forms.ModuleBayForm
 
 
 
 
@@ -2271,7 +2273,6 @@ class DeviceBayView(generic.ObjectView):
 
 
 class DeviceBayCreateView(generic.ComponentCreateView):
 class DeviceBayCreateView(generic.ComponentCreateView):
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
-    form = forms.DeviceBayCreateForm
     model_form = forms.DeviceBayForm
     model_form = forms.DeviceBayForm
 
 
 
 
@@ -2397,7 +2398,6 @@ class InventoryItemEditView(generic.ObjectEditView):
 
 
 class InventoryItemCreateView(generic.ComponentCreateView):
 class InventoryItemCreateView(generic.ComponentCreateView):
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
-    form = forms.InventoryItemCreateForm
     model_form = forms.InventoryItemForm
     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.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
 from django.db import transaction
 from django.db.models import ProtectedError
 from django.db.models import ProtectedError
+from django.forms.widgets import HiddenInput
 from django.http import HttpResponse
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.html import escape
 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.views.generic import View
 from django_tables2.export import TableExport
 from django_tables2.export import TableExport
 
 
+from dcim.forms.object_create import ComponentCreateForm
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from extras.signals import clear_webhooks
 from extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
@@ -674,33 +676,46 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 # Device/VirtualMachine components
 # Device/VirtualMachine components
 #
 #
 
 
-# TODO: Replace with BulkCreateView
 class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
     """
     """
     Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
     Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
     """
     """
     queryset = None
     queryset = None
-    form = None
+    form = ComponentCreateForm
     model_form = None
     model_form = None
-    template_name = 'generic/object_edit.html'
+    template_name = 'dcim/component_create.html'
+    patterned_fields = ('name', 'label')
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'add')
         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, {
         return render(request, self.template_name, {
-            'obj': self.queryset.model(),
+            'obj': self.queryset.model,
             'obj_type': self.queryset.model._meta.verbose_name,
             'obj_type': self.queryset.model._meta.verbose_name,
-            'form': form,
+            'replication_form': form,
+            'form': model_form,
             'return_url': self.get_return_url(request),
             'return_url': self.get_return_url(request),
         })
         })
 
 
     def post(self, 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)
         self.validate_form(request, form)
 
 
         if form.is_valid() and not form.errors:
         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 redirect(self.get_return_url(request))
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
+            'obj': self.queryset.model,
             'obj_type': self.queryset.model._meta.verbose_name,
             'obj_type': self.queryset.model._meta.verbose_name,
-            'form': form,
+            'replication_form': form,
+            'form': model_form,
             'return_url': self.get_return_url(request),
             '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
         Validate form values and set errors on the form object as they are detected. If
         no errors are found, signal success messages.
         no errors are found, signal success messages.
         """
         """
-
         logger = logging.getLogger('netbox.views.ComponentCreateView')
         logger = logging.getLogger('netbox.views.ComponentCreateView')
         if form.is_valid():
         if form.is_valid():
             new_components = []
             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>
                 </h5>
                 <div class="card-body">
                 <div class="card-body">
                     {% if object.mark_connected %}
                     {% 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 %}
                     {% 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>
                             <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>
                                 <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>
                                 </td>
                             </tr>
                             </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>
             </div>
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_right_page object %}
             {% plugin_right_page object %}
         </div>
         </div>
     </div>
     </div>

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

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

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

@@ -129,6 +129,7 @@
                 {% endif %}
                 {% endif %}
                 </div>
                 </div>
             </div>
             </div>
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_right_page object %}
             {% plugin_right_page object %}
         </div>
         </div>
     </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>
                 </div>
             {% endif %}
             {% endif %}
             {% include 'ipam/inc/panels/fhrp_groups.html' %}
             {% include 'ipam/inc/panels/fhrp_groups.html' %}
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_right_page object %}
             {% plugin_right_page object %}
         </div>
         </div>
     </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 %}
                                 {% endif %}
                             </td>
                             </td>
                         </tr>
                         </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>
                         <tr>
                             <th scope="row">Manufacturer</th>
                             <th scope="row">Manufacturer</th>
                             <td>
                             <td>

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

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

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

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

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

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

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

@@ -29,29 +29,35 @@
         </div>
         </div>
       {% endif %}
       {% 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 %}
         {% csrf_token %}
-        {% for field in form.hidden_fields %}
-          {{ field }}
-        {% endfor %}
 
 
         {% block form %}
         {% block form %}
           {% if form.Meta.fieldsets %}
           {% if form.Meta.fieldsets %}
 
 
+            {# Render hidden fields #}
+            {% for field in form.hidden_fields %}
+              {{ field }}
+            {% endfor %}
+
             {# Render grouped fields according to Form #}
             {# Render grouped fields according to Form #}
             {% for group, fields in form.Meta.fieldsets %}
             {% for group, fields in form.Meta.fieldsets %}
-              <div class="field-group my-5">
+              <div class="field-group mb-5">
                 <div class="row mb-2">
                 <div class="row mb-2">
                   <h5 class="offset-sm-3">{{ group }}</h5>
                   <h5 class="offset-sm-3">{{ group }}</h5>
                 </div>
                 </div>
                 {% for name in fields %}
                 {% 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 %}
                 {% endfor %}
               </div>
               </div>
             {% endfor %}
             {% endfor %}
 
 
             {% if form.custom_fields %}
             {% if form.custom_fields %}
-              <div class="field-group my-5">
+              <div class="field-group mb-5">
                 <div class="row mb-2">
                 <div class="row mb-2">
                   <h5 class="offset-sm-3">Custom Fields</h5>
                   <h5 class="offset-sm-3">Custom Fields</h5>
                 </div>
                 </div>
@@ -60,7 +66,7 @@
             {% endif %}
             {% endif %}
 
 
             {% if form.comments %}
             {% if form.comments %}
-              <div class="field-group my-5">
+              <div class="field-group mb-5">
                 <h5 class="text-center">Comments</h5>
                 <h5 class="text-center">Comments</h5>
                 {% render_field form.comments %}
                 {% render_field form.comments %}
               </div>
               </div>
@@ -68,7 +74,7 @@
 
 
           {% else %}
           {% else %}
             {# Render all fields in a single group #}
             {# 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 %}
               {% block form_fields %}{% render_form form %}{% endblock %}
             </div>
             </div>
           {% endif %}
           {% endif %}

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

@@ -346,11 +346,7 @@ class ExpandableNameField(forms.CharField):
         if not self.help_text:
         if not self.help_text:
             self.help_text = """
             self.help_text = """
                 Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
                 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):
     def to_python(self, value):

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

@@ -275,12 +275,18 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
         required=False,
         required=False,
-        label='Parent interface'
+        label='Parent interface',
+        query_params={
+            'virtual_machine_id': '$virtual_machine',
+        }
     )
     )
     bridge = DynamicModelChoiceField(
     bridge = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
         required=False,
         required=False,
-        label='Bridged interface'
+        label='Bridged interface',
+        query_params={
+            'virtual_machine_id': '$virtual_machine',
+        }
     )
     )
     vlan_group = DynamicModelChoiceField(
     vlan_group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
@@ -293,6 +299,7 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         label='Untagged VLAN',
         label='Untagged VLAN',
         query_params={
         query_params={
             'group_id': '$vlan_group',
             'group_id': '$vlan_group',
+            'available_on_virtualmachine': '$virtual_machine',
         }
         }
     )
     )
     tagged_vlans = DynamicModelMultipleChoiceField(
     tagged_vlans = DynamicModelMultipleChoiceField(
@@ -301,6 +308,7 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         label='Tagged VLANs',
         label='Tagged VLANs',
         query_params={
         query_params={
             'group_id': '$vlan_group',
             'group_id': '$vlan_group',
+            'available_on_virtualmachine': '$virtual_machine',
         }
         }
     )
     )
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
@@ -324,15 +332,3 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
         help_texts = {
         help_texts = {
             'mode': INTERFACE_MODE_HELP_TEXT,
             '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 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__ = (
 __all__ = (
     'VMInterfaceCreateForm',
     'VMInterfaceCreateForm',
 )
 )
 
 
 
 
-class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonForm):
-    model = VMInterface
-    virtual_machine = DynamicModelChoiceField(
-        queryset=VirtualMachine.objects.all()
-    )
+class VMInterfaceCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         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):
 class VMInterfaceCreateView(generic.ComponentCreateView):
     queryset = VMInterface.objects.all()
     queryset = VMInterface.objects.all()
     form = forms.VMInterfaceCreateForm
     form = forms.VMInterfaceCreateForm
     model_form = forms.VMInterfaceForm
     model_form = forms.VMInterfaceForm
+    patterned_fields = ('name',)
 
 
 
 
 class VMInterfaceEditView(generic.ObjectEditView):
 class VMInterfaceEditView(generic.ObjectEditView):