Repository: netbox-community/netbox

Issue:
Allow modifying the assigned component for InventoryItems in the UI
### NetBox version

v3.3.9

### Feature type

Change to existing functionality

### Proposed functionality

Currently it is possible to change the assigned component of an InventoryItem via. the API. It is however not possible to change the associated InventoryItem via. the UI. This means that the only way to create the association is by going to the component details view and clicking "Add Inventory Item". It is not possible to change the relationship afterwards and it is not possible to remove the relationship either.

A component field should be added to InventoryItem form to allow changing the associated component. The challenge is that the component field is a generic foreign key to any of the component types, so we need to figure out a slick way to present this to the user. We do it the following way for IP assignment currently:

![image](https://user-images.githubusercontent.com/400797/206907026-3a7b9b81-4b2f-4f6a-b425-c144eb41a9ee.png)

However as there are currently 7 types of device components that support associating an InventoryItem, this might be a little much in the UI.

### Use case

Changing the associated component might be useful adding the inventoryitem after the component has been added to the device. There might also be times where a component should be removed but the inventory item should stay. Similarly it might be useful to change the associated component, if an SFP is moved to another port for example.

Lastly it will allow the user to do in the UI what is already possible in the API.

### Database changes

None

### External dependencies

None


Discussion:
I looked a little at this, and it doesn't seem like we have a good pattern for these cases (generic relations).

This is what we currently do for IP assignment:

https://github.com/netbox-community/netbox/blob/860805ba8245f5d5a4401dca041b21678b55f060/netbox/templates/ipam/ipaddress_edit.html#L32-L56

https://github.com/netbox-community/netbox/blob/860805ba8245f5d5a4401dca041b21678b55f060/netbox/ipam/forms/models.py#L414-L434

https://github.com/netbox-community/netbox/blob/860805ba8245f5d5a4401dca041b21678b55f060/netbox/ipam/forms/models.py#L451-L462

You have any ideas here @jeremystretch - I can try to replicate this functionality for the inventoryitem -> component relationship, but it doesn't seem very clean. However I'm not sure how much we're using/will be using generic relationships in the future, so it might not be worth it to spend a bunch of time on a generic solution.

Resolving patch:
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -1549,15 +1549,63 @@ class InventoryItemForm(DeviceComponentForm):
         queryset=Manufacturer.objects.all(),
         required=False
     )
-    component_type = ContentTypeChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=MODULAR_COMPONENT_MODELS,
+
+    # Assigned component selectors
+    consoleport = DynamicModelChoiceField(
+        queryset=ConsolePort.objects.all(),
         required=False,
-        widget=forms.HiddenInput
+        query_params={
+            'device_id': '$device'
+        },
+        label=_('Console port')
     )
-    component_id = forms.IntegerField(
+    consoleserverport = DynamicModelChoiceField(
+        queryset=ConsoleServerPort.objects.all(),
         required=False,
-        widget=forms.HiddenInput
+        query_params={
+            'device_id': '$device'
+        },
+        label=_('Console server port')
+    )
+    frontport = DynamicModelChoiceField(
+        queryset=FrontPort.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device'
+        },
+        label=_('Front port')
+    )
+    interface = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device'
+        },
+        label=_('Interface')
+    )
+    poweroutlet = DynamicModelChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device'
+        },
+        label=_('Power outlet')
+    )
+    powerport = DynamicModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device'
+        },
+        label=_('Power port')
+    )
+    rearport = DynamicModelChoiceField(
+        queryset=RearPort.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device'
+        },
+        label=_('Rear port')
     )
 
     fieldsets = (
@@ -1565,22 +1613,61 @@ class InventoryItemForm(DeviceComponentForm):
         ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
     )
 
+    class Meta:
+        model = InventoryItem
+        fields = [
+            'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
+            'description', 'tags',
+        ]
+
     def __init__(self, *args, **kwargs):
+        instance = kwargs.get('instance')
+        initial = kwargs.get('initial', {}).copy()
+        component_type = initial.get('component_type')
+        component_id = initial.get('component_id')
+
+        # Used for picking the default active tab for component selection
+        self.no_component = True
+
+        if instance:
+            # When editing set the initial value for component selectin
+            for component_model in ContentType.objects.filter(MODULAR_COMPONENT_MODELS):
+                if type(instance.component) is component_model.model_class():
+                    initial[component_model.model] = instance.component
+                    self.no_component = False
+                    break
+        elif component_type and component_id:
+            # When adding the InventoryItem from a component page
+            if content_type := ContentType.objects.filter(MODULAR_COMPONENT_MODELS).filter(pk=component_type).first():
+                if component := content_type.model_class().objects.filter(pk=component_id).first():
+                    initial[content_type.model] = component
+                    self.no_component = False
+
+        kwargs['initial'] = initial
+
         super().__init__(*args, **kwargs)
 
         # Specifically allow editing the device of IntentoryItems
         if self.instance.pk:
             self.fields['device'].disabled = False
 
-    class Meta:
-        model = InventoryItem
-        fields = [
-            'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
-            'description', 'component_type', 'component_id', 'tags',
+    def clean(self):
+        super().clean()
+
+        # Handle object assignment
+        selected_objects = [
+            field for field in (
+                'consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'
+            ) if self.cleaned_data[field]
         ]
+        if len(selected_objects) > 1:
+            raise forms.ValidationError("An InventoryItem can only be assigned to a single component.")
+        elif selected_objects:
+            self.instance.component = self.cleaned_data[selected_objects[0]]
+        else:
+            self.instance.component = None
 
 
-#
 # Device component roles
 #
 
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -1146,3 +1146,8 @@ def clean(self):
             # When moving an InventoryItem to another device, remove any associated component
             if self.component and self.component.device != self.device:
                 self.component = None
+        else:
+            if self.component and self.component.device != self.device:
+                raise ValidationError({
+                    "device": "Cannot assign inventory item to component on another device"
+                })
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -2913,23 +2913,14 @@ class InventoryItemView(generic.ObjectView):
 class InventoryItemEditView(generic.ObjectEditView):
     queryset = InventoryItem.objects.all()
     form = forms.InventoryItemForm
+    template_name = 'dcim/inventoryitem_edit.html'
 
 
 class InventoryItemCreateView(generic.ComponentCreateView):
     queryset = InventoryItem.objects.all()
     form = forms.InventoryItemCreateForm
     model_form = forms.InventoryItemForm
-
-    def alter_object(self, instance, request):
-        # Set component (if any)
-        component_type = request.GET.get('component_type')
-        component_id = request.GET.get('component_id')
-
-        if component_type and component_id:
-            content_type = get_object_or_404(ContentType, pk=component_type)
-            instance.component = get_object_or_404(content_type.model_class(), pk=component_id)
-
-        return instance
+    template_name = 'dcim/inventoryitem_edit.html'
 
 
 @register_model_view(InventoryItem, 'delete')


Test patch: