From 0604e1a12753edd46e5964c04e5739b741f457d1 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 17 Feb 2022 17:03:17 +1100
Subject: [PATCH] Adds API endpoint for installing stock items into other stock
 items

- Requires more filtering for the Part API
- Adds more BOM related functionality for Part model
- Removes old server-side form
---
 InvenTree/InvenTree/version.py                |   5 +-
 InvenTree/part/api.py                         |  18 +++
 InvenTree/part/models.py                      |  30 ++++
 InvenTree/stock/api.py                        |  26 +++
 InvenTree/stock/forms.py                      |  50 ------
 InvenTree/stock/serializers.py                |  58 +++++++
 InvenTree/stock/templates/stock/item.html     |  13 +-
 .../stock/templates/stock/item_base.html      |  15 +-
 .../stock/templates/stock/item_install.html   |  33 ----
 InvenTree/stock/urls.py                       |   1 -
 InvenTree/stock/views.py                      | 149 ------------------
 InvenTree/templates/js/translated/stock.js    |  65 ++++++++
 12 files changed, 209 insertions(+), 254 deletions(-)
 delete mode 100644 InvenTree/stock/templates/stock/item_install.html

diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py
index 19235f0e0a..f7ba0f7a68 100644
--- a/InvenTree/InvenTree/version.py
+++ b/InvenTree/InvenTree/version.py
@@ -12,11 +12,14 @@ import common.models
 INVENTREE_SW_VERSION = "0.6.0 dev"
 
 # InvenTree API version
-INVENTREE_API_VERSION = 24
+INVENTREE_API_VERSION = 25
 
 """
 Increment this API version number whenever there is a significant change to the API that any clients need to know about
 
+v25 -> 2022-02-17
+    - Adds ability to filter "part" list endpoint by "in_bom_for" argument
+
 v24 -> 2022-02-10
     - Adds API endpoint for deleting (cancelling) build order outputs
 
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 4c52b87520..5a9439420d 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -995,6 +995,24 @@ class PartList(generics.ListCreateAPIView):
             except (ValueError, Part.DoesNotExist):
                 pass
 
+        # Filter only parts which are in the "BOM" for a given part
+        in_bom_for = params.get('in_bom_for', None)
+
+        if in_bom_for is not None:
+            try:
+                in_bom_for = Part.objects.get(pk=in_bom_for)
+
+
+                # Extract a list of parts within the BOM
+                bom_parts = in_bom_for.get_parts_in_bom()
+                print("bom_parts:", bom_parts)
+                print([p.pk for p in bom_parts])
+
+                queryset = queryset.filter(pk__in=[p.pk for p in bom_parts])
+
+            except (ValueError, Part.DoesNotExist):
+                pass
+
         # Filter by whether the BOM has been validated (or not)
         bom_valid = params.get('bom_valid', None)
 
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index b312937e30..eba308df68 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -483,6 +483,36 @@ class Part(MPTTModel):
     def __str__(self):
         return f"{self.full_name} - {self.description}"
 
+    def get_parts_in_bom(self):
+        """
+        Return a list of all parts in the BOM for this part.
+        Takes into account substitutes, variant parts, and inherited BOM items
+        """
+
+        parts = set()
+
+        for bom_item in self.get_bom_items():
+            for part in bom_item.get_valid_parts_for_allocation():
+                parts.add(part)
+
+        return parts
+
+    def check_if_part_in_bom(self, other_part):
+        """
+        Check if the other_part is in the BOM for this part.
+
+        Note:
+            - Accounts for substitute parts
+            - Accounts for variant BOMs
+        """
+
+        for bom_item in self.get_bom_items():
+            if other_part in bom_item.get_valid_parts_for_allocation():
+                return True
+
+        # No matches found
+        return False
+
     def check_add_to_bom(self, parent, raise_error=False, recursive=True):
         """
         Check if this Part can be added to the BOM of another part.
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index a13c7f37c3..7bcd89623c 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -109,6 +109,31 @@ class StockItemSerialize(generics.CreateAPIView):
         return context
 
 
+class StockItemInstall(generics.CreateAPIView):
+    """
+    API endpoint for installing a particular stock item into this stock item.
+
+    - stock_item.part must be in the BOM for this part
+    - stock_item must currently be "in stock"
+    - stock_item must be serialized (and not belong to another item)
+    """
+
+    queryset = StockItem.objects.none()
+    serializer_class = StockSerializers.InstallStockItemSerializer
+
+    def get_serializer_context(self):
+
+        context = super().get_serializer_context()
+        context['request'] = self.request
+
+        try:
+            context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None))
+        except:
+            pass
+
+        return context
+
+
 class StockAdjustView(generics.CreateAPIView):
     """
     A generic class for handling stocktake actions.
@@ -1256,6 +1281,7 @@ stock_api_urls = [
     # Detail views for a single stock item
     url(r'^(?P<pk>\d+)/', include([
         url(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
+        url(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
         url(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
     ])),
 
diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index dcbf722997..aa1d5e510f 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -162,56 +162,6 @@ class SerializeStockForm(HelperForm):
         ]
 
 
-class InstallStockForm(HelperForm):
-    """
-    Form for manually installing a stock item into another stock item
-
-    TODO: Migrate this form to the modern API forms interface
-    """
-
-    part = forms.ModelChoiceField(
-        queryset=Part.objects.all(),
-        widget=forms.HiddenInput()
-    )
-
-    stock_item = forms.ModelChoiceField(
-        required=True,
-        queryset=StockItem.objects.filter(StockItem.IN_STOCK_FILTER),
-        help_text=_('Stock item to install')
-    )
-
-    to_install = forms.BooleanField(
-        widget=forms.HiddenInput(),
-        required=False,
-    )
-
-    notes = forms.CharField(
-        required=False,
-        help_text=_('Notes')
-    )
-
-    class Meta:
-        model = StockItem
-        fields = [
-            'part',
-            'stock_item',
-            # 'quantity_to_install',
-            'notes',
-        ]
-
-    def clean(self):
-
-        data = super().clean()
-
-        stock_item = data.get('stock_item', None)
-        quantity = data.get('quantity_to_install', None)
-
-        if stock_item and quantity and quantity > stock_item.quantity:
-            raise ValidationError({'quantity_to_install': _('Must not exceed available quantity')})
-
-        return data
-
-
 class UninstallStockForm(forms.ModelForm):
     """
     Form for uninstalling a stock item which is installed in another item.
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index cdc844095b..90a08a536a 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -36,6 +36,7 @@ import InvenTree.helpers
 import InvenTree.serializers
 from InvenTree.serializers import InvenTreeDecimalField, extract_int
 
+import part.models as part_models
 from part.serializers import PartBriefSerializer
 
 
@@ -391,6 +392,63 @@ class SerializeStockItemSerializer(serializers.Serializer):
         )
 
 
+class InstallStockItemSerializer(serializers.Serializer):
+    """
+    Serializer for installing a stock item into a given part
+    """
+
+    stock_item = serializers.PrimaryKeyRelatedField(
+        queryset=StockItem.objects.all(),
+        many=False,
+        required=True,
+        allow_null=False,
+        label=_('Stock Item'),
+        help_text=_('Select stock item to install'),
+    )
+
+    note = serializers.CharField(
+        label=_('Note'),
+        required=False,
+        allow_blank=True,
+    )
+
+    def validate_stock_item(self, stock_item):
+        """
+        Validate the selected stock item
+        """
+
+        if not stock_item.in_stock:
+            # StockItem must be in stock to be "installed"
+            raise ValidationError(_("Stock item is unavailable"))
+
+        # Extract the "parent" item - the item into which the stock item will be installed
+        parent_item = self.context['item']
+        parent_part = parent_item.part
+
+        if not parent_part.check_if_part_in_bom(stock_item.part):
+            raise ValidationError(_("Selected part is not in the Bill of Materials"))
+
+        return stock_item
+
+    def save(self):
+        """ Install the selected stock item into this one """
+        
+        data = self.validated_data
+
+        stock_item = data['stock_item']
+        note = data.get('note', '')
+
+        parent_item = self.context['item']
+        request = self.context['request']
+
+        parent_item.installStockItem(
+            stock_item,
+            stock_item.quantity,
+            request.user,
+            note,
+        )
+
+
 class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """
     Serializer for a simple tree view
diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html
index 333360a95e..f42a768069 100644
--- a/InvenTree/stock/templates/stock/item.html
+++ b/InvenTree/stock/templates/stock/item.html
@@ -183,16 +183,11 @@
 
     $('#stock-item-install').click(function() {
 
-        launchModalForm(
-            "{% url 'stock-item-install' item.pk %}",
-            {
-                data: {
-                    'part': {{ item.part.pk }},
-                    'install_item': true,
-                },
-                reload: true,
+        installStockItem({{ item.pk }}, {{ item.part.pk }}, {
+            onSuccess: function(response) {
+                $("#installed-table").bootstrapTable('refresh');
             }
-        );
+        });
     });
 
     loadInstalledInTable(
diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html
index f28a231f2b..7692d632f0 100644
--- a/InvenTree/stock/templates/stock/item_base.html
+++ b/InvenTree/stock/templates/stock/item_base.html
@@ -98,7 +98,9 @@
             <li><a class='dropdown-item' href='#' id='stock-uninstall' title='{% trans "Uninstall stock item" %}'><span class='fas fa-unlink'></span> {% trans "Uninstall" %}</a></li>
             {% else %}
             {% if item.part.get_used_in %}
-            <li><a class='dropdown-item' href='#' id='stock-install-in' title='{% trans "Install stock item" %}'><span class='fas fa-link'></span> {% trans "Install" %}</a></li>
+            <!--    
+                <li><a class='dropdown-item' href='#' id='stock-install-in' title='{% trans "Install stock item" %}'><span class='fas fa-link'></span> {% trans "Install" %}</a></li>
+            -->
             {% endif %}
             {% endif %}
         </ul>
@@ -442,16 +444,7 @@ $("#stock-serialize").click(function() {
 
 $('#stock-install-in').click(function() {
 
-    launchModalForm(
-        "{% url 'stock-item-install' item.pk %}",
-        {
-            data: {
-                'part': {{ item.part.pk }},
-                'install_in': true,
-            },
-            reload: true,
-        }
-    );
+    // TODO - Launch dialog to install this item *into* another stock item
 });
 
 $('#stock-uninstall').click(function() {
diff --git a/InvenTree/stock/templates/stock/item_install.html b/InvenTree/stock/templates/stock/item_install.html
deleted file mode 100644
index 8a94f304d3..0000000000
--- a/InvenTree/stock/templates/stock/item_install.html
+++ /dev/null
@@ -1,33 +0,0 @@
-{% extends "modal_form.html" %}
-{% load i18n %}
-
-{% block pre_form_content %}
-
-{% if install_item %}
-<p>
-    {% trans "Install another Stock Item into this item." %}
-</p>
-<p>
-    {% trans "Stock items can only be installed if they meet the following criteria" %}:
-
-    <ul>
-        <li>{% trans "The Stock Item links to a Part which is in the BOM for this Stock Item" %}</li>
-        <li>{% trans "The Stock Item is currently in stock" %}</li>
-        <li>{% trans "The Stock Item is serialized and does not belong to another item" %}</li>
-    </ul>
-</p>
-{% elif install_in %}
-<p>
-    {% trans "Install this Stock Item in another stock item." %}
-</p>
-<p>
-    {% trans "Stock items can only be installed if they meet the following criteria" %}:
-
-    <ul>
-        <li>{% trans "The part associated to this Stock Item belongs to another part's BOM" %}</li>
-        <li>{% trans "This Stock Item is serialized and does not belong to another item" %}</li>
-    </ul>
-</p>
-{% endif %}
-
-{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py
index 7f35904b51..b2536e0b97 100644
--- a/InvenTree/stock/urls.py
+++ b/InvenTree/stock/urls.py
@@ -24,7 +24,6 @@ stock_item_detail_urls = [
     url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
     url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
     url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'),
-    url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'),
 
     url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
 
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 6c89db0f2f..9aa70255b1 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -465,155 +465,6 @@ class StockItemQRCode(QRCodeView):
             return None
 
 
-class StockItemInstall(AjaxUpdateView):
-    """
-    View for manually installing stock items into
-    a particular stock item.
-
-    In contrast to the StockItemUninstall view,
-    only a single stock item can be installed at once.
-
-    The "part" to be installed must be provided in the GET query parameters.
-
-    """
-
-    model = StockItem
-    form_class = StockForms.InstallStockForm
-    ajax_form_title = _('Install Stock Item')
-    ajax_template_name = "stock/item_install.html"
-
-    part = None
-
-    def get_params(self):
-        """ Retrieve GET parameters """
-
-        # Look at GET params
-        self.part_id = self.request.GET.get('part', None)
-        self.install_in = self.request.GET.get('install_in', False)
-        self.install_item = self.request.GET.get('install_item', False)
-
-        if self.part_id is None:
-            # Look at POST params
-            self.part_id = self.request.POST.get('part', None)
-
-        try:
-            self.part = Part.objects.get(pk=self.part_id)
-        except (ValueError, Part.DoesNotExist):
-            self.part = None
-
-    def get_stock_items(self):
-        """
-        Return a list of stock items suitable for displaying to the user.
-
-        Requirements:
-        - Items must be in stock
-        - Items must be in BOM of stock item
-        - Items must be serialized
-        """
-
-        # Filter items in stock
-        items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
-
-        # Filter serialized stock items
-        items = items.exclude(serial__isnull=True).exclude(serial__exact='')
-
-        if self.part:
-            # Filter for parts to install this item in
-            if self.install_in:
-                # Get parts using this part
-                allowed_parts = self.part.get_used_in()
-                # Filter
-                items = items.filter(part__in=allowed_parts)
-
-            # Filter for parts to install in this item
-            if self.install_item:
-                # Get all parts which can be installed into this part
-                allowed_parts = self.part.get_installed_part_options()
-                # Filter
-                items = items.filter(part__in=allowed_parts)
-
-        return items
-
-    def get_context_data(self, **kwargs):
-        """ Retrieve parameters and update context """
-
-        ctx = super().get_context_data(**kwargs)
-
-        # Get request parameters
-        self.get_params()
-
-        ctx.update({
-            'part': self.part,
-            'install_in': self.install_in,
-            'install_item': self.install_item,
-        })
-
-        return ctx
-
-    def get_initial(self):
-
-        initials = super().get_initial()
-
-        items = self.get_stock_items()
-
-        # If there is a single stock item available, we can use it!
-        if items.count() == 1:
-            item = items.first()
-            initials['stock_item'] = item.pk
-
-        if self.part:
-            initials['part'] = self.part
-
-        try:
-            # Is this stock item being installed in the other stock item?
-            initials['to_install'] = self.install_in or not self.install_item
-        except AttributeError:
-            pass
-
-        return initials
-
-    def get_form(self):
-
-        form = super().get_form()
-
-        form.fields['stock_item'].queryset = self.get_stock_items()
-
-        return form
-
-    def post(self, request, *args, **kwargs):
-
-        self.get_params()
-
-        form = self.get_form()
-
-        valid = form.is_valid()
-
-        if valid:
-            # We assume by this point that we have a valid stock_item and quantity values
-            data = form.cleaned_data
-
-            other_stock_item = data['stock_item']
-            # Quantity will always be 1 for serialized item
-            quantity = 1
-            notes = data['notes']
-
-            # Get stock item
-            this_stock_item = self.get_object()
-
-            if data['to_install']:
-                # Install this stock item into the other stock item
-                other_stock_item.installStockItem(this_stock_item, quantity, request.user, notes)
-            else:
-                # Install the other stock item into this one
-                this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes)
-
-        data = {
-            'form_valid': valid,
-        }
-
-        return self.renderJsonResponse(request, form, data=data)
-
-
 class StockItemUninstall(AjaxView, FormMixin):
     """
     View for uninstalling one or more StockItems,
diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js
index 9be9d2afa1..10b1b71073 100644
--- a/InvenTree/templates/js/translated/stock.js
+++ b/InvenTree/templates/js/translated/stock.js
@@ -46,6 +46,7 @@
     editStockLocation,
     exportStock,
     findStockItemBySerialNumber,
+    installStockItem,
     loadInstalledInTable,
     loadStockAllocationTable,
     loadStockLocationTable,
@@ -2960,3 +2961,67 @@ function loadInstalledInTable(table, options) {
         }
     });
 }
+
+
+/*
+ * Launch a dialog to install a stock item into another stock item
+ */
+function installStockItem(stock_item_id, part_id, options={}) {
+
+    var html = `
+    <div class='alert alert-block alert-info'>
+        <strong>{% trans "Install another stock item into this item" %}</strong><br>
+        {% trans "Stock items can only be installed if they meet the following criteria" %}:<br>
+        <ul>
+            <li>{% trans "The Stock Item links to a Part which is the BOM for this Stock Item" %}</li>
+            <li>{% trans "The Stock Item is currently available in stock" %}</li>
+            <li>{% trans "The Stock Item is serialized and does not belong to another item" %}</li>
+        </ul>
+    </div>`;
+
+    constructForm(
+        `/api/stock/${stock_item_id}/install/`,
+        {
+            method: 'POST',
+            fields: {
+                part: {
+                    type: 'related field',
+                    required: 'true',
+                    label: '{% trans "Part" %}',
+                    help_text: '{% trans "Select part to install" %}',
+                    model: 'part',
+                    api_url: '{% url "api-part-list" %}',
+                    auto_fill: true,
+                    filters: {
+                        trackable: true,
+                        in_bom_for: part_id,
+                    }
+                },
+                stock_item: {
+                    filters: {
+                        part_detail: true,
+                        in_stock: true,
+                        serialized: true,
+                    },
+                    adjustFilters: function(filters, opts) {
+                        var part = getFormFieldValue('part', {}, opts);
+
+                        if (part) {
+                            filters.part = part;
+                        }
+
+                        return filters;
+                    }
+                }
+            },
+            confirm: true,
+            title: '{% trans "Install Stock Item" %}',
+            preFormContent: html,
+            onSuccess: function(response) {
+                if (options.onSuccess) {
+                    options.onSuccess(response);
+                }
+            }
+        }
+    );
+}