From 0604e1a12753edd46e5964c04e5739b741f457d1 Mon Sep 17 00:00:00 2001
From: Oliver
Date: Thu, 17 Feb 2022 17:03:17 +1100
Subject: [PATCH 1/3] 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\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 @@
{% trans "Uninstall" %}
{% else %}
{% if item.part.get_used_in %}
- {% trans "Install" %}
+
{% endif %}
{% endif %}
@@ -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 %}
-
- {% trans "Install another Stock Item into this item." %}
-
-
- {% trans "Stock items can only be installed if they meet the following criteria" %}:
-
-
- - {% trans "The Stock Item links to a Part which is in the BOM for this Stock Item" %}
- - {% trans "The Stock Item is currently in stock" %}
- - {% trans "The Stock Item is serialized and does not belong to another item" %}
-
-
-{% elif install_in %}
-
- {% trans "Install this Stock Item in another stock item." %}
-
-
- {% trans "Stock items can only be installed if they meet the following criteria" %}:
-
-
- - {% trans "The part associated to this Stock Item belongs to another part's BOM" %}
- - {% trans "This Stock Item is serialized and does not belong to another item" %}
-
-
-{% 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 = `
+
+
{% trans "Install another stock item into this item" %}
+ {% trans "Stock items can only be installed if they meet the following criteria" %}:
+
+ - {% trans "The Stock Item links to a Part which is the BOM for this Stock Item" %}
+ - {% trans "The Stock Item is currently available in stock" %}
+ - {% trans "The Stock Item is serialized and does not belong to another item" %}
+
+
`;
+
+ 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);
+ }
+ }
+ }
+ );
+}
From f485bc7d53500b75803ed82c871546634ab61c3c Mon Sep 17 00:00:00 2001
From: Oliver
Date: Thu, 17 Feb 2022 17:04:51 +1100
Subject: [PATCH 2/3] PEP fixes
---
InvenTree/part/api.py | 1 -
InvenTree/stock/forms.py | 3 ---
InvenTree/stock/serializers.py | 3 +--
3 files changed, 1 insertion(+), 6 deletions(-)
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 5a9439420d..c179cb1665 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -1002,7 +1002,6 @@ class PartList(generics.ListCreateAPIView):
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)
diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index aa1d5e510f..ef65b25cd9 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -8,7 +8,6 @@ from __future__ import unicode_literals
from django import forms
from django.forms.utils import ErrorDict
from django.utils.translation import ugettext_lazy as _
-from django.core.exceptions import ValidationError
from mptt.fields import TreeNodeChoiceField
@@ -16,8 +15,6 @@ from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField
-from part.models import Part
-
from .models import StockLocation, StockItem, StockItemTracking
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index 90a08a536a..941219da6c 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -36,7 +36,6 @@ import InvenTree.helpers
import InvenTree.serializers
from InvenTree.serializers import InvenTreeDecimalField, extract_int
-import part.models as part_models
from part.serializers import PartBriefSerializer
@@ -432,7 +431,7 @@ class InstallStockItemSerializer(serializers.Serializer):
def save(self):
""" Install the selected stock item into this one """
-
+
data = self.validated_data
stock_item = data['stock_item']
From 3226a1906fa437a98290d6096ee8101e83a42164 Mon Sep 17 00:00:00 2001
From: Oliver
Date: Thu, 17 Feb 2022 17:17:09 +1100
Subject: [PATCH 3/3] Critical bug fix: Check if serial numbers already exist
when creating new StockItem
---
InvenTree/stock/api.py | 25 ++++++++++++++++++++++++-
1 file changed, 24 insertions(+), 1 deletion(-)
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 7bcd89623c..9723e01c09 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -528,11 +528,34 @@ class StockList(generics.ListCreateAPIView):
serial_numbers = data.get('serial_numbers', '')
# Assign serial numbers for a trackable part
- if serial_numbers and part.trackable:
+ if serial_numbers:
+
+ if not part.trackable:
+ raise ValidationError({
+ 'serial_numbers': [_("Serial numbers cannot be supplied for a non-trackable part")]
+ })
# If serial numbers are specified, check that they match!
try:
serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt())
+
+ # Determine if any of the specified serial numbers already exist!
+ existing = []
+
+ for serial in serials:
+ if part.checkIfSerialNumberExists(serial):
+ existing.append(serial)
+
+ if len(existing) > 0:
+
+ msg = _("The following serial numbers already exist")
+ msg += " : "
+ msg += ",".join([str(e) for e in existing])
+
+ raise ValidationError({
+ 'serial_numbers': [msg],
+ })
+
except DjangoValidationError as e:
raise ValidationError({
'quantity': e.messages,