From eba0d15fe4998fd4ac1c02c8fed7fa46597b5ef9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 4 Oct 2021 16:11:15 +1100 Subject: [PATCH 01/48] Rough layout of javascript function - allocateStockToBuild - provide build ID and part ID - optionally provide output ID - optionally provide list of part ID to filter against --- InvenTree/templates/js/translated/build.js | 59 +++++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index d359d6cf4e..c28a926e19 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -694,8 +694,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { }, columns: [ { - field: 'pk', - visible: false, + visible: true, + switchable: false, + checkbox: true, }, { field: 'sub_part_detail.full_name', @@ -817,6 +818,60 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { } + +/** + * Allocate stock items to a build + * + * arguments: + * - buildId: ID / PK value for the build + * - partId: ID / PK value for the part being built + * + * options: + * - outputId: ID / PK of the associated build output (or null for untracked items) + * - parts: List of ID values for filtering against specific sub parts + */ +function allocateStockToBuild(buildId, partId, options={}) { + + // ID of the associated "build output" (or null) + var outputId = options.output || null; + + // Extract list of BOM items (or empty list) + var subPartIds = options.parts || []; + + var bomItemQueryParams = { + part: partId, + sub_part_detail: true, + sub_part_trackable: outputId != null + }; + + inventreeGet( + '{% url "api-bom-list" %}', + bomItemQueryParams, + { + success: function(response) { + + // List of BOM item objects we are interested in + var bomItems = []; + + for (var idx = 0; idx < response.length; idx++) { + var item = response[idx]; + + var subPartId = item.sub_part; + + // Check if we are interested in this item + if (subPartIds.length > 0 && !subPartIds.includes(subPartId)) { + continue; + } + + bomItems.push(item); + } + } + } + ); +} + + + function loadBuildTable(table, options) { // Display a table of Build objects From 49dc88abbcc3bceb28a0ba77e000e3463b41bba1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 4 Oct 2021 17:31:38 +1100 Subject: [PATCH 02/48] Add configurable options for the stockitem renderer --- InvenTree/templates/js/translated/forms.js | 2 +- InvenTree/templates/js/translated/helpers.js | 2 +- .../js/translated/model_renderers.js | 25 ++++++++++++++++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 0e815f8c6d..f1f8f632e5 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1168,7 +1168,7 @@ function addSecondaryModal(field, fields, options) { /* - * Initializea single related-field + * Initialize a single related-field * * argument: * - modal: DOM identifier for the modal window diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 6e3f7f0c95..164452952d 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -65,7 +65,7 @@ function imageHoverIcon(url) { function thumbnailImage(url) { if (!url) { - url = '/static/img/blank_img.png'; + url = blankImage(); } // TODO: Support insertion of custom classes diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 389d5a650f..eeb3cdbad2 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -49,9 +49,18 @@ function renderStockItem(name, data, parameters, options) { var image = data.part_detail.thumbnail || data.part_detail.image || blankImage(); - var html = ``; + var html = ''; + + var render_part_detail = true; - html += ` ${data.part_detail.full_name || data.part_detail.name}`; + if ("render_part_detail" in parameters) { + render_part_detail = parameters["render_part_detail"]; + } + + if (render_part_detail) { + html += ``; + html += ` ${data.part_detail.full_name || data.part_detail.name}`; + } if (data.serial && data.quantity == 1) { html += ` - {% trans "Serial Number" %}: ${data.serial}`; @@ -59,10 +68,20 @@ function renderStockItem(name, data, parameters, options) { html += ` - {% trans "Quantity" %}: ${data.quantity}`; } - if (data.part_detail.description) { + if (render_part_detail && data.part_detail.description) { html += `

${data.part_detail.description}

`; } + var render_location_detail = false; + + if ("render_location_detail" in parameters) { + render_location_detail = parameters["render_location_detail"]; + } + + if (render_location_detail && data.location_detail) { + html += `

${data.location_detail.pathstring}

`; + } + return html; } From cd657daf5026cc21aed04ebfccd58b755f19ae6e Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 4 Oct 2021 17:41:09 +1100 Subject: [PATCH 03/48] Fixes for stockitem rendering in API forms - Also improved rendering for other models --- InvenTree/templates/js/translated/forms.js | 2 +- .../js/translated/model_renderers.js | 42 ++++++++++++------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index f1f8f632e5..c303c55d17 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1884,7 +1884,7 @@ function constructChoiceInput(name, parameters) { */ function constructRelatedFieldInput(name) { - var html = ``; + var html = ``; // Don't load any options - they will be filled via an AJAX request diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index eeb3cdbad2..86a12013a2 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -37,7 +37,7 @@ function renderCompany(name, data, parameters, options) { html += `${data.name} - ${data.description}`; - html += `{% trans "Company ID" %}: ${data.pk}`; + html += `{% trans "Company ID" %}: ${data.pk}`; return html; } @@ -53,8 +53,8 @@ function renderStockItem(name, data, parameters, options) { var render_part_detail = true; - if ("render_part_detail" in parameters) { - render_part_detail = parameters["render_part_detail"]; + if ('render_part_detail' in parameters) { + render_part_detail = parameters['render_part_detail']; } if (render_part_detail) { @@ -62,24 +62,38 @@ function renderStockItem(name, data, parameters, options) { html += ` ${data.part_detail.full_name || data.part_detail.name}`; } + html += ''; + if (data.serial && data.quantity == 1) { - html += ` - {% trans "Serial Number" %}: ${data.serial}`; + html += `{% trans "Serial Number" %}: ${data.serial}`; } else { - html += ` - {% trans "Quantity" %}: ${data.quantity}`; + html += `{% trans "Quantity" %}: ${data.quantity}`; } + html += ''; + if (render_part_detail && data.part_detail.description) { html += `

${data.part_detail.description}

`; } + var render_stock_id = true; + + if ('render_stock_id' in parameters) { + render_stock_id = parameters['render_stock_id']; + } + + if (render_stock_id) { + html += `{% trans "Stock ID" %}: ${data.pk}`; + } + var render_location_detail = false; - if ("render_location_detail" in parameters) { - render_location_detail = parameters["render_location_detail"]; + if ('render_location_detail' in parameters) { + render_location_detail = parameters['render_location_detail']; } if (render_location_detail && data.location_detail) { - html += `

${data.location_detail.pathstring}

`; + html += ` - ${data.location_detail.name}`; } return html; @@ -98,7 +112,7 @@ function renderStockLocation(name, data, parameters, options) { html += ` - ${data.description}`; } - html += `{% trans "Location ID" %}: ${data.pk}`; + html += `{% trans "Location ID" %}: ${data.pk}`; return html; } @@ -115,7 +129,7 @@ function renderBuild(name, data, parameters, options) { var html = select2Thumbnail(image); html += `${data.reference} - ${data.quantity} x ${data.part_detail.full_name}`; - html += `{% trans "Build ID" %}: ${data.pk}`; + html += `{% trans "Build ID" %}: ${data.pk}`; html += `

${data.title}

`; @@ -135,7 +149,7 @@ function renderPart(name, data, parameters, options) { html += ` - ${data.description}`; } - html += `{% trans "Part ID" %}: ${data.pk}`; + html += `{% trans "Part ID" %}: ${data.pk}`; return html; } @@ -187,7 +201,7 @@ function renderPartCategory(name, data, parameters, options) { html += ` - ${data.description}`; } - html += `{% trans "Category ID" %}: ${data.pk}`; + html += `{% trans "Category ID" %}: ${data.pk}`; return html; } @@ -224,7 +238,7 @@ function renderManufacturerPart(name, data, parameters, options) { html += ` ${data.manufacturer_detail.name} - ${data.MPN}`; html += ` - ${data.part_detail.full_name}`; - html += `{% trans "Manufacturer Part ID" %}: ${data.pk}`; + html += `{% trans "Manufacturer Part ID" %}: ${data.pk}`; return html; } @@ -253,7 +267,7 @@ function renderSupplierPart(name, data, parameters, options) { html += ` ${data.supplier_detail.name} - ${data.SKU}`; html += ` - ${data.part_detail.full_name}`; - html += `{% trans "Supplier Part ID" %}: ${data.pk}`; + html += `{% trans "Supplier Part ID" %}: ${data.pk}`; return html; From 22d6d49b970e9440a0480f7310a4e32e0192dc60 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 4 Oct 2021 17:42:47 +1100 Subject: [PATCH 04/48] Create a select2 stockitem input and a quantity input for each allocation row --- InvenTree/templates/js/translated/build.js | 123 ++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index c28a926e19..5715af9f3f 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -20,6 +20,7 @@ */ /* exported + allocateStockToBuild, editBuildOrder, loadAllocationTable, loadBuildOrderAllocationTable, @@ -844,6 +845,52 @@ function allocateStockToBuild(buildId, partId, options={}) { sub_part_trackable: outputId != null }; + function renderBomItemRow(bom_item, quantity) { + + var pk = bom_item.pk; + var sub_part = bom_item.sub_part_detail; + + var thumb = thumbnailImage(bom_item.sub_part_detail.thumbnail); + + var delete_button = `
`; + + delete_button += makeIconButton( + 'fa-times icon-red', + 'button-part-remove', + pk, + '{% trans "Remove row" %}', + ); + + delete_button += `
`; + + var quantity_input = constructNumberInput(pk, { + value: quantity || 0, + min_value: 0, + title: '{% trans "Specify stock allocation quantity" %}', + }); + + var stock_input = constructRelatedFieldInput(`stock_query_${pk}`); + + var html = ` + + + ${thumb} ${sub_part.full_name} + + + ${stock_input} + + + ${quantity_input} + + + ${delete_button} + + + `; + + return html; + } + inventreeGet( '{% url "api-bom-list" %}', bomItemQueryParams, @@ -851,7 +898,9 @@ function allocateStockToBuild(buildId, partId, options={}) { success: function(response) { // List of BOM item objects we are interested in - var bomItems = []; + var bom_items = []; + + var table_entries = ""; for (var idx = 0; idx < response.length; idx++) { var item = response[idx]; @@ -863,8 +912,78 @@ function allocateStockToBuild(buildId, partId, options={}) { continue; } - bomItems.push(item); + // TODO: Ignore items which are already fully allocated + + bom_items.push(item); + + // Add HTML + table_entries += renderBomItemRow(item); } + + if (bom_items.length == 0) { + + showAlertDialog( + '{% trans "Select Parts" %}', + '{% trans "You must select at least one part to allocate" %}', + ); + + return; + } + + var modal = createNewModal({ + title: '{% trans "Allocate Stock to Build" %}', + }); + + var html = ` + + + + + + + + + + + ${table_entries} + +
{% trans "Part" %}{% trans "Stock Item" %}{% trans "Quantity" %}
+ `; + + constructFormBody({}, { + preFormContent: html, + fields: {}, + confirm: true, + confirmMessage: '{% trans "Confirm Stock Allocation" %}', + modal: modal, + afterRender: function(fields, options) { + + bom_items.forEach(function(bom_item) { + initializeRelatedField( + { + name: `stock_query_${bom_item.pk}`, + api_url: '{% url "api-stock-list" %}', + filters: { + part: bom_item.sub_part, + in_stock: true, + part_detail: true, + location_detail: true, + }, + model: 'stockitem', + render_part_detail: false, + render_location_detail: true, + // TODO: Auto-assign value? + }, + null, + options, + ); + }); + + }, + onSubmit: function(fields) { + // TODO + } + }); } } ); From 99c1819c69746e7670caae112d01bcb8cd0038fc Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 4 Oct 2021 18:26:30 +1100 Subject: [PATCH 05/48] Implement API endpoint and serializer for allocation of stock items --- InvenTree/build/api.py | 111 +++++++++++++++++++++++++++- InvenTree/build/serializers.py | 131 ++++++++++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 7 deletions(-) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index eb6d42cc6d..0c0efea64d 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -5,10 +5,15 @@ JSON API for the Build app # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.conf.urls import url, include +from django.utils.translation import ugettext_lazy as _ -from rest_framework import filters -from rest_framework import generics +from django.db import transaction +from django.conf.urls import url, include +from django.core.exceptions import ValidationError as DjangoValidationError + +from rest_framework import filters, generics, serializers, status +from rest_framework.serializers import ValidationError +from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as rest_filters @@ -19,6 +24,7 @@ from InvenTree.status_codes import BuildStatus from .models import Build, BuildItem, BuildOrderAttachment from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer +from .serializers import BuildAllocationSerializer class BuildFilter(rest_filters.FilterSet): @@ -181,6 +187,100 @@ class BuildDetail(generics.RetrieveUpdateAPIView): serializer_class = BuildSerializer +class BuildAllocate(generics.CreateAPIView): + """ + API endpoint to allocate stock items to a build order + + - The BuildOrder object is specified by the URL + - Items to allocate are specified as a list called "items" with the following options: + - bom_item: pk value of a given BomItem object (must match the part associated with this build) + - stock_item: pk value of a given StockItem object + - quantity: quantity to allocate + - output: StockItem (build order output) to allocate stock against (optional) + """ + + queryset = Build.objects.none() + + serializer_class = BuildAllocationSerializer + + def get_build(self): + """ + Returns the BuildOrder associated with this API endpoint + """ + + pk = self.kwargs.get('pk', None) + + try: + build = Build.objects.get(pk=pk) + except (Build.DoesNotExist, ValueError): + raise ValidationError(_("Matching build order does not exist")) + + return build + + def get_serializer_context(self): + + context = super().get_serializer_context() + + context['build'] = self.get_build() + + return context + + def create(self, request, *args, **kwargs): + + # Which build are we receiving against? + build = self.get_build() + + # Validate the serialized data + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Allocate the stock items + try: + self.allocate_items(build, serializer) + except DjangoValidationError as exc: + # Re-throw a django error as a DRF error + raise ValidationError(detail=serializers.as_serializer_error(exc)) + + headers = self.get_success_headers(serializer.data) + + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + @transaction.atomic + def allocate_items(self, build, serializer): + """ + Allocate the provided stock items to this order. + + At this point, most of the heavy lifting has been done for us by the DRF serializer. + + We have a list of "items" each a dict containing: + + - bom_item: A validated BomItem object which matches this build + - stock_item: A validated StockItem object which matches the bom_item + - quantity: A validated numerical quantity which does not exceed the available stock + - output: A validated StockItem object to assign stock against (optional) + + """ + + data = serializer.validated_data + + items = data.get('items', []) + + for item in items: + + bom_item = item['bom_item'] + stock_item = item['stock_item'] + quantity = item['quantity'] + output = item.get('output', None) + + # Create a new BuildItem to allocate stock + build_item = BuildItem.objects.create( + build=build, + stock_item=stock_item, + quantity=quantity, + install_into=output + ) + + class BuildItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of BuildItem objects @@ -291,7 +391,10 @@ build_api_urls = [ ])), # Build Detail - url(r'^(?P\d+)/', BuildDetail.as_view(), name='api-build-detail'), + url(r'^(?P\d+)/', include([ + url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), + ])), # Build List url(r'^.*$', BuildList.as_view(), name='api-build-list'), diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 69e3a7aed0..f29cc1de2c 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -5,16 +5,21 @@ JSON serializers for Build API # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.utils.translation import ugettext_lazy as _ + from django.db.models import Case, When, Value from django.db.models import BooleanField from rest_framework import serializers +from rest_framework.serializers import ValidationError from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief -from stock.serializers import StockItemSerializerBrief -from stock.serializers import LocationSerializer +from stock.models import StockItem +from stock.serializers import StockItemSerializerBrief, LocationSerializer + +from part.models import Part, BomItem from part.serializers import PartSerializer, PartBriefSerializer from users.serializers import OwnerSerializer @@ -22,7 +27,9 @@ from .models import Build, BuildItem, BuildOrderAttachment class BuildSerializer(InvenTreeModelSerializer): - """ Serializes a Build object """ + """ + Serializes a Build object + """ url = serializers.CharField(source='get_absolute_url', read_only=True) status_text = serializers.CharField(source='get_status_display', read_only=True) @@ -109,6 +116,124 @@ class BuildSerializer(InvenTreeModelSerializer): ] +class BuildAllocationItemSerializer(serializers.Serializer): + """ + A serializer for allocating a single stock item against a build order + """ + + bom_item = serializers.PrimaryKeyRelatedField( + queryset=BomItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('BOM Item'), + ) + + def validate_bom_item(self, bom_item): + + build = self.context['build'] + + # BomItem must point to the same 'part' as the parent build + if build.part != bom_item.part: + raise ValidationError(_("bom_item.part must point to the same part as the build order")) + + return bom_item + + stock_item = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Stock Item'), + ) + + quantity = serializers.DecimalField( + max_digits=15, + decimal_places=5, + min_value=0, + required=True + ) + + output = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.filter(is_building=True), + many=False, + allow_null=True, + required=False, + label=_('Build Output'), + ) + + class Meta: + fields = [ + 'bom_item', + 'stock_item', + 'quantity', + 'output', + ] + + def is_valid(self, raise_exception=False): + + if super().is_valid(raise_exception): + + data = self.validated_data + + bom_item = data['bom_item'] + stock_item = data['stock_item'] + quantity = data['quantity'] + output = data.get('output', None) + + build = self.context['build'] + + # TODO: Check that the "stock item" is valid for the referenced "sub_part" + # Note: Because of allow_variants options, it may not be a direct match! + + # TODO: Check that the quantity does not exceed the available amount from the stock item + + # Output *must* be set for trackable parts + if output is None and bom_item.sub_part.trackable: + self._errors['output'] = _('Build output must be specified for allocation of tracked parts') + + # Output *cannot* be set for un-tracked parts + if output is not None and not bom_item.sub_part.trackable: + self._errors['output'] = _('Build output cannot be specified for allocation of untracked parts') + + if self._errors and raise_exception: + raise ValidationError(self.errors) + + return not bool(self._errors) + + +class BuildAllocationSerializer(serializers.Serializer): + """ + DRF serializer for allocation stock items against a build order + """ + + items = BuildAllocationItemSerializer(many=True) + + class Meta: + fields = [ + 'items', + ] + + def is_valid(self, raise_exception=False): + """ + Validation + """ + + super().is_valid(raise_exception) + + data = self.validated_data + + items = data.get('items', []) + + if len(items) == 0: + self._errors['items'] = _('Allocation items must be provided') + + if self._errors and raise_exception: + raise ValidationError(self.errors) + + return not bool(self._errors) + + class BuildItemSerializer(InvenTreeModelSerializer): """ Serializes a BuildItem object """ From 957867f504877fe125ea06410a2c7b0ba18751e2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 4 Oct 2021 18:53:10 +1100 Subject: [PATCH 06/48] Implement unit tests for new build allocation API feature --- InvenTree/InvenTree/api_tester.py | 12 +++++ InvenTree/build/fixtures/build.yaml | 2 +- InvenTree/build/test_api.py | 84 ++++++++++++++++++++++++++++- 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index e2bdae1d8f..7af7c82914 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -141,3 +141,15 @@ class InvenTreeAPITestCase(APITestCase): self.assertEqual(response.status_code, expected_code) return response + + def options(self, url, expected_code=None): + """ + Issue an OPTIONS request + """ + + response = self.client.options(url, format='json') + + if expected_code is not None: + self.assertEqual(response.status_code, expected_code) + + return response diff --git a/InvenTree/build/fixtures/build.yaml b/InvenTree/build/fixtures/build.yaml index cc645f9696..1506c9402a 100644 --- a/InvenTree/build/fixtures/build.yaml +++ b/InvenTree/build/fixtures/build.yaml @@ -3,7 +3,7 @@ - model: build.build pk: 1 fields: - part: 25 + part: 100 # Build against part 100 "Bob" batch: 'B1' reference: "0001" title: 'Building 7 parts' diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index a1d0c3df9f..6fabd625e9 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from django.urls import reverse from part.models import Part -from build.models import Build +from build.models import Build, BuildItem from InvenTree.status_codes import BuildStatus from InvenTree.api_tester import InvenTreeAPITestCase @@ -36,6 +36,88 @@ class BuildAPITest(InvenTreeAPITestCase): super().setUp() +class BuildAllocationTest(BuildAPITest): + """ + Unit tests for allocation of stock items against a build order. + + For this test, we will be using Build ID=1; + + - This points to Part 100 (see fixture data in part.yaml) + - This Part already has a BOM with 4 items (see fixture data in bom.yaml) + - There are no BomItem objects yet created for this build + + """ + + def setUp(self): + + super().setUp() + + self.assignRole('build.add') + self.assignRole('build.change') + + self.url = reverse('api-build-allocate', kwargs={'pk': 1}) + + self.build = Build.objects.get(pk=1) + + # Record number of build items which exist at the start of each test + self.n = BuildItem.objects.count() + + def test_build_data(self): + """ + Check that our assumptions about the particular BuildOrder are correct + """ + + self.assertEqual(self.build.part.pk, 100) + + # There should be 4x BOM items we can use + self.assertEqual(self.build.part.bom_items.count(), 4) + + # No items yet allocated to this build + self.assertEqual(self.build.allocated_stock.count(), 0) + + + def test_get(self): + """ + A GET request to the endpoint should return an error + """ + + self.get(self.url, expected_code=405) + + def test_options(self): + """ + An OPTIONS request to the endpoint should return information about the endpoint + """ + + response = self.options(self.url, expected_code=200) + + self.assertIn("API endpoint to allocate stock items to a build order", str(response.data)) + + def test_empty(self): + """ + Test without any POST data + """ + + # Initially test with an empty data set + data = self.post(self.url, {}, expected_code=400).data + + self.assertIn('This field is required', str(data['items'])) + + # Now test but with an empty items list + data = self.post( + self.url, + { + "items": [] + }, + expected_code=400 + ).data + + self.assertIn('Allocation items must be provided', str(data['items'])) + + # No new BuildItem objects have been created during this test + self.assertEqual(self.n, BuildItem.objects.count()) + + + class BuildListTest(BuildAPITest): """ Tests for the BuildOrder LIST API From 75a35773c6193c7672ffbbdf7d9231ed49834abe Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 4 Oct 2021 20:44:15 +1100 Subject: [PATCH 07/48] More unit tests --- InvenTree/build/test_api.py | 108 ++++++++++++++++++++++++++++++- InvenTree/part/fixtures/bom.yaml | 9 ++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index 6fabd625e9..549df80d87 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -23,6 +23,7 @@ class BuildAPITest(InvenTreeAPITestCase): 'location', 'bom', 'build', + 'stock', ] # Required roles to access Build API endpoints @@ -116,7 +117,112 @@ class BuildAllocationTest(BuildAPITest): # No new BuildItem objects have been created during this test self.assertEqual(self.n, BuildItem.objects.count()) - + def test_missing(self): + """ + Test with missing data + """ + + # Missing quantity + data = self.post( + self.url, + { + "items": [ + { + "bom_item": 1, # M2x4 LPHS + "stock_item": 2, # 5,000 screws available + } + ] + }, + expected_code=400 + ).data + + self.assertIn('This field is required', str(data["items"][0]["quantity"])) + + # Missing bom_item + data = self.post( + self.url, + { + "items": [ + { + "stock_item": 2, + "quantity": 5000, + } + ] + }, + expected_code=400 + ).data + + self.assertIn("This field is required", str(data["items"][0]["bom_item"])) + + # Missing stock_item + data = self.post( + self.url, + { + "items": [ + { + "bom_item": 1, + "quantity": 5000, + } + ] + }, + expected_code=400 + ).data + + self.assertIn("This field is required", str(data["items"][0]["stock_item"])) + + # No new BuildItem objects have been created during this test + self.assertEqual(self.n, BuildItem.objects.count()) + + def test_invalid_bom_item(self): + """ + Test by passing an invalid BOM item + """ + + data = self.post( + self.url, + { + "items": [ + { + "bom_item": 5, + "stock_item": 11, + "quantity": 500, + } + ] + }, + expected_code=400 + ).data + + self.assertIn('must point to the same part', str(data)) + + def test_valid_data(self): + """ + Test with valid data. + This should result in creation of a new BuildItem object + """ + + data = self.post( + self.url, + { + "items": [ + { + "bom_item": 1, + "stock_item": 2, + "quantity": 5000, + } + ] + }, + expected_code=201 + ).data + + # A new BuildItem should have been created + self.assertEqual(self.n + 1, BuildItem.objects.count()) + + allocation = BuildItem.objects.last() + + self.assertEqual(allocation.quantity, 5000) + self.assertEqual(allocation.bom_item.pk, 1) + self.assertEqual(allocation.stock_item.pk, 2) + class BuildListTest(BuildAPITest): """ diff --git a/InvenTree/part/fixtures/bom.yaml b/InvenTree/part/fixtures/bom.yaml index a9e1bed6f0..e879b8381f 100644 --- a/InvenTree/part/fixtures/bom.yaml +++ b/InvenTree/part/fixtures/bom.yaml @@ -30,4 +30,11 @@ fields: part: 100 sub_part: 50 - quantity: 3 \ No newline at end of file + quantity: 3 + +- model: part.bomitem + pk: 5 + fields: + part: 1 + sub_part: 5 + quantity: 3 From a9d5b776d378e9b2d372e77befca2d66d07ffc0c Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 4 Oct 2021 22:41:59 +1100 Subject: [PATCH 08/48] Adds error handling of nested form fields in the API form processing layer - Muy complejo - But the results speak for themselves! -This will need to be rolled out into the coming refactor... --- InvenTree/templates/js/translated/forms.js | 128 ++++++++++++++++++--- 1 file changed, 113 insertions(+), 15 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index c303c55d17..2fc650e0e6 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -864,6 +864,78 @@ function clearFormErrors(options) { $(options.modal).find('#non-field-errors').html(''); } +/* + * Display form error messages as returned from the server, + * specifically for errors returned in an array. + * + * We need to know the unique ID of each item in the array, + * and the array length must equal the length of the array returned from the server + * + * arguments: + * - response: The JSON error response from the server + * - parent: The name of the parent field e.g. "items" + * - options: The global options struct + * + * options: + * - nested: A map of nested ID values for the "parent" field + * e.g. + * { + * "items": [ + * 1, + * 2, + * 12 + * ] + * } + * + */ + +function handleNestedErrors(errors, field_name, options) { + + var error_list = errors[field_name]; + + // Ignore null or empty list + if (!error_list) { + return; + } + + var nest_list = nest_list = options["nested"][field_name]; + + // Nest list must be provided! + if (!nest_list) { + console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`); + return; + } + + for (var idx = 0; idx < error_list.length; idx++) { + + var error_item = error_list[idx]; + + if (idx >= nest_list.length) { + console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`); + break; + } + + // Extract the particular ID of the nested item + var nest_id = nest_list[idx]; + + // Here, error_item is a map of field names to error messages + for (sub_field_name in error_item) { + var errors = error_item[sub_field_name]; + + // Find the target (nested) field + var target = `${field_name}_${sub_field_name}_${nest_id}`; + + for (var ii = errors.length-1; ii >= 0; ii--) { + + var error_text = errors[ii]; + + addFieldErrorMessage(target, error_text, ii, options); + } + } + } +} + + /* * Display form error messages as returned from the server. @@ -913,28 +985,30 @@ function handleFormErrors(errors, fields, options) { for (var field_name in errors) { - // Add the 'has-error' class - $(options.modal).find(`#div_id_${field_name}`).addClass('has-error'); + if (field_name in fields) { - var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`); + var field = fields[field_name]; - var field_errors = errors[field_name]; + if ((field.type == "field") && ("child" in field)) { + // This is a "nested" field + handleNestedErrors(errors, field_name, options); + } else { + // This is a "simple" field - if (field_errors && !first_error_field && isFieldVisible(field_name, options)) { - first_error_field = field_name; - } + var field_errors = errors[field_name]; - // Add an entry for each returned error message - for (var ii = field_errors.length-1; ii >= 0; ii--) { + if (field_errors && !first_error_field && isFieldVisible(field_name, options)) { + first_error_field = field_name; + } - var error_text = field_errors[ii]; + // Add an entry for each returned error message + for (var ii = field_errors.length-1; ii >= 0; ii--) { - var error_html = ` - - ${error_text} - `; + var error_text = field_errors[ii]; - field_dom.append(error_html); + addFieldErrorMessage(field_name, error_text, ii, options); + } + } } } @@ -952,6 +1026,30 @@ function handleFormErrors(errors, fields, options) { } +/* + * Add a rendered error message to the provided field + */ +function addFieldErrorMessage(field_name, error_text, error_idx, options) { + + // Add the 'has-error' class + $(options.modal).find(`#div_id_${field_name}`).addClass('has-error'); + + var field_dom = $(options.modal).find(`#errors-${field_name}`); + + if (field_dom) { + + var error_html = ` + + ${error_text} + `; + + field_dom.append(error_html); + } else { + console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}`); + } +} + + function isFieldVisible(field, options) { return $(options.modal).find(`#div_id_${field}`).is(':visible'); From 074466f08744e5bc89fd7bd4cff378eb529d2007 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 4 Oct 2021 23:20:03 +1100 Subject: [PATCH 09/48] Updates for stock allocation form - Data submission - Nested error handling - Button callbacks to remove rows --- InvenTree/build/serializers.py | 7 + InvenTree/templates/js/translated/build.js | 210 +++++++++++++-------- 2 files changed, 142 insertions(+), 75 deletions(-) diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index f29cc1de2c..536aced1a2 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -154,6 +154,13 @@ class BuildAllocationItemSerializer(serializers.Serializer): required=True ) + def validate_quantity(self, quantity): + + if quantity <= 0: + raise ValidationError(_("Quantity must be greater than zero")) + + return quantity + output = serializers.PrimaryKeyRelatedField( queryset=StockItem.objects.filter(is_building=True), many=False, diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 5715af9f3f..14523e445a 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -378,52 +378,18 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { // Primary key of the 'sub_part' var pk = $(this).attr('pk'); - // Launch form to allocate new stock against this output - launchModalForm('{% url "build-item-create" %}', { - success: reloadTable, - data: { - part: pk, - build: buildId, - install_into: outputId, - }, - secondary: [ - { - field: 'stock_item', - label: '{% trans "New Stock Item" %}', - title: '{% trans "Create new Stock Item" %}', - url: '{% url "stock-item-create" %}', - data: { - part: pk, - }, + allocateStockToBuild( + buildId, + partId, + { + success: function(data) { + console.log("here we go I guess"); }, - ], - callback: [ - { - field: 'stock_item', - action: function(value) { - inventreeGet( - `/api/stock/${value}/`, {}, - { - success: function(response) { - - // How many items are actually available for the given stock item? - var available = response.quantity - response.allocated; - - var field = getFieldByName('#modal-form', 'quantity'); - - // Allocation quantity initial value - var initial = field.attr('value'); - - if (available < initial) { - field.val(available); - } - } - } - ); - } - } - ] - }); + parts: [ + parseInt(pk), + ] + } + ); }); // Callback for 'buy' button @@ -831,18 +797,18 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { * - outputId: ID / PK of the associated build output (or null for untracked items) * - parts: List of ID values for filtering against specific sub parts */ -function allocateStockToBuild(buildId, partId, options={}) { +function allocateStockToBuild(build_id, part_id, options={}) { // ID of the associated "build output" (or null) - var outputId = options.output || null; + var output_id = options.output || null; // Extract list of BOM items (or empty list) - var subPartIds = options.parts || []; + var sub_part_ids = options.parts || []; - var bomItemQueryParams = { - part: partId, + var query_params = { + part: part_id, sub_part_detail: true, - sub_part_trackable: outputId != null + sub_part_trackable: output_id != null }; function renderBomItemRow(bom_item, quantity) { @@ -856,29 +822,50 @@ function allocateStockToBuild(buildId, partId, options={}) { delete_button += makeIconButton( 'fa-times icon-red', - 'button-part-remove', + 'button-row-remove', pk, '{% trans "Remove row" %}', ); delete_button += ``; - var quantity_input = constructNumberInput(pk, { - value: quantity || 0, - min_value: 0, - title: '{% trans "Specify stock allocation quantity" %}', - }); + var quantity_input = constructField( + `items_quantity_${pk}`, + { + type: 'decimal', + min_value: 0, + value: quantity || 0, + title: '{% trans "Specify stock allocation quantity" %}', + required: true, + }, + { + hideLabels: true, + } + ); - var stock_input = constructRelatedFieldInput(`stock_query_${pk}`); + var stock_input = constructField( + `items_stock_item_${pk}`, + { + type: 'related field', + required: 'true', + }, + { + hideLabels: true, + } + ); + + // var stock_input = constructRelatedFieldInput(`items_stock_item_${pk}`); var html = ` - + ${thumb} ${sub_part.full_name} ${stock_input} + + ${quantity_input} @@ -893,7 +880,7 @@ function allocateStockToBuild(buildId, partId, options={}) { inventreeGet( '{% url "api-bom-list" %}', - bomItemQueryParams, + query_params, { success: function(response) { @@ -905,10 +892,10 @@ function allocateStockToBuild(buildId, partId, options={}) { for (var idx = 0; idx < response.length; idx++) { var item = response[idx]; - var subPartId = item.sub_part; + var sub_part_id = item.sub_part; // Check if we are interested in this item - if (subPartIds.length > 0 && !subPartIds.includes(subPartId)) { + if (sub_part_ids.length > 0 && !sub_part_ids.includes(sub_part_id)) { continue; } @@ -930,16 +917,14 @@ function allocateStockToBuild(buildId, partId, options={}) { return; } - var modal = createNewModal({ - title: '{% trans "Allocate Stock to Build" %}', - }); - + // Create table of parts var html = ` + @@ -950,18 +935,20 @@ function allocateStockToBuild(buildId, partId, options={}) {
{% trans "Part" %} {% trans "Stock Item" %}{% trans "Allocated" %} {% trans "Quantity" %}
`; - constructFormBody({}, { - preFormContent: html, + constructForm(`/api/build/${build_id}/allocate/`, { + method: 'POST', fields: {}, + preFormContent: html, confirm: true, - confirmMessage: '{% trans "Confirm Stock Allocation" %}', - modal: modal, + confirmMessage: '{% trans "Confirm stock allocation" %}', + title: '{% trans "Allocate Stock Items to Build Order" %}', afterRender: function(fields, options) { - + + // Initialize select2 fields bom_items.forEach(function(bom_item) { initializeRelatedField( { - name: `stock_query_${bom_item.pk}`, + name: `items_stock_item_${bom_item.pk}`, api_url: '{% url "api-stock-list" %}', filters: { part: bom_item.sub_part, @@ -979,10 +966,83 @@ function allocateStockToBuild(buildId, partId, options={}) { ); }); + // Add button callbacks + $(options.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(options.modal).find(`#allocation_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, options) { + + // Extract elements from the form + var data = { + items: [] + }; + + var item_pk_values = []; + + bom_items.forEach(function(item) { + + var quantity = getFormFieldValue( + `items_quantity_${item.pk}`, + {}, + { + modal: options.modal, + }, + ); + + var stock_item = getFormFieldValue( + `items_stock_item_${item.pk}`, + {}, + { + modal: options.modal, + } + ); + + if (quantity != null) { + data.items.push({ + bom_item: item.pk, + stock_item: stock_item, + quantity: quantity + }); + + item_pk_values.push(item.pk); + } + }); + + // Provide nested values + options.nested = { + "items": item_pk_values + }; + + inventreePut( + options.url, + data, + { + method: 'POST', + success: function(response) { + // Hide the modal + $(options.modal).modal('hide'); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, options); + break; + default: + $(options.modal).modal('hide'); + showApiError(xhr); + break; + } + } + } + ); }, - onSubmit: function(fields) { - // TODO - } }); } } From 5ded23fd9929836f131c00cb743fa3f5f0057251 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 4 Oct 2021 23:44:23 +1100 Subject: [PATCH 10/48] Fixes for serializer validation - Note: use the validate() function! - Ref: https://www.django-rest-framework.org/api-guide/serializers/ - override serializer.save() functionality for simpler operation --- InvenTree/build/api.py | 58 +---------------- InvenTree/build/models.py | 22 ------- InvenTree/build/serializers.py | 110 ++++++++++++++++++++++++--------- InvenTree/build/tests.py | 28 --------- InvenTree/build/urls.py | 1 - InvenTree/order/serializers.py | 35 +++++------ 6 files changed, 99 insertions(+), 155 deletions(-) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 0c0efea64d..d53956cbfb 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -218,6 +218,9 @@ class BuildAllocate(generics.CreateAPIView): return build def get_serializer_context(self): + """ + Provide the Build object to the serializer context + """ context = super().get_serializer_context() @@ -225,61 +228,6 @@ class BuildAllocate(generics.CreateAPIView): return context - def create(self, request, *args, **kwargs): - - # Which build are we receiving against? - build = self.get_build() - - # Validate the serialized data - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - # Allocate the stock items - try: - self.allocate_items(build, serializer) - except DjangoValidationError as exc: - # Re-throw a django error as a DRF error - raise ValidationError(detail=serializers.as_serializer_error(exc)) - - headers = self.get_success_headers(serializer.data) - - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - - @transaction.atomic - def allocate_items(self, build, serializer): - """ - Allocate the provided stock items to this order. - - At this point, most of the heavy lifting has been done for us by the DRF serializer. - - We have a list of "items" each a dict containing: - - - bom_item: A validated BomItem object which matches this build - - stock_item: A validated StockItem object which matches the bom_item - - quantity: A validated numerical quantity which does not exceed the available stock - - output: A validated StockItem object to assign stock against (optional) - - """ - - data = serializer.validated_data - - items = data.get('items', []) - - for item in items: - - bom_item = item['bom_item'] - stock_item = item['stock_item'] - quantity = item['quantity'] - output = item.get('output', None) - - # Create a new BuildItem to allocate stock - build_item = BuildItem.objects.create( - build=build, - stock_item=stock_item, - quantity=quantity, - install_into=output - ) - class BuildItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of BuildItem objects diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 084f9ab2db..3224554156 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -1190,28 +1190,6 @@ class BuildItem(models.Model): super().save() - def validate_unique(self, exclude=None): - """ - Test that this BuildItem object is "unique". - Essentially we do not want a stock_item being allocated to a Build multiple times. - """ - - super().validate_unique(exclude) - - items = BuildItem.objects.exclude(id=self.id).filter( - build=self.build, - stock_item=self.stock_item, - install_into=self.install_into - ) - - if items.exists(): - msg = _("BuildItem must be unique for build, stock_item and install_into") - raise ValidationError({ - 'build': msg, - 'stock_item': msg, - 'install_into': msg - }) - def clean(self): """ Check validity of the BuildItem model. The following checks are performed: diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 536aced1a2..adbf7361eb 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -5,6 +5,8 @@ JSON serializers for Build API # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.db import transaction + from django.utils.translation import ugettext_lazy as _ from django.db.models import Case, When, Value @@ -16,6 +18,8 @@ from rest_framework.serializers import ValidationError from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief +import InvenTree.helpers + from stock.models import StockItem from stock.serializers import StockItemSerializerBrief, LocationSerializer @@ -147,6 +151,13 @@ class BuildAllocationItemSerializer(serializers.Serializer): label=_('Stock Item'), ) + def validate_stock_item(self, stock_item): + + if not stock_item.in_stock: + raise ValidationError(_("Item must be in stock")) + + return stock_item + quantity = serializers.DecimalField( max_digits=15, decimal_places=5, @@ -177,36 +188,45 @@ class BuildAllocationItemSerializer(serializers.Serializer): 'output', ] - def is_valid(self, raise_exception=False): + def validate(self, data): - if super().is_valid(raise_exception): + super().validate(data) - data = self.validated_data + bom_item = data['bom_item'] + stock_item = data['stock_item'] + quantity = data['quantity'] + output = data.get('output', None) - bom_item = data['bom_item'] - stock_item = data['stock_item'] - quantity = data['quantity'] - output = data.get('output', None) + build = self.context['build'] - build = self.context['build'] + # TODO: Check that the "stock item" is valid for the referenced "sub_part" + # Note: Because of allow_variants options, it may not be a direct match! - # TODO: Check that the "stock item" is valid for the referenced "sub_part" - # Note: Because of allow_variants options, it may not be a direct match! + # Check that the quantity does not exceed the available amount from the stock item + q = stock_item.unallocated_quantity() - # TODO: Check that the quantity does not exceed the available amount from the stock item + if quantity > q: - # Output *must* be set for trackable parts - if output is None and bom_item.sub_part.trackable: - self._errors['output'] = _('Build output must be specified for allocation of tracked parts') + q = InvenTree.helpers.clean_decimal(q) - # Output *cannot* be set for un-tracked parts - if output is not None and not bom_item.sub_part.trackable: - self._errors['output'] = _('Build output cannot be specified for allocation of untracked parts') + raise ValidationError({ + 'quantity': _(f"Available quantity ({q}) exceeded") + }) + + # Output *must* be set for trackable parts + if output is None and bom_item.sub_part.trackable: + raise ValidationError({ + 'output': _('Build output must be specified for allocation of tracked parts') + }) + + # Output *cannot* be set for un-tracked parts + if output is not None and not bom_item.sub_part.trackable: - if self._errors and raise_exception: - raise ValidationError(self.errors) + raise ValidationError({ + 'output': _('Build output cannot be specified for allocation of untracked parts') + }) - return not bool(self._errors) + return data class BuildAllocationSerializer(serializers.Serializer): @@ -221,24 +241,56 @@ class BuildAllocationSerializer(serializers.Serializer): 'items', ] - def is_valid(self, raise_exception=False): + def validate(self, data): """ Validation """ - - super().is_valid(raise_exception) - - data = self.validated_data + + super().validate(data) items = data.get('items', []) if len(items) == 0: - self._errors['items'] = _('Allocation items must be provided') + raise ValidationError(_('Allocation items must be provided')) - if self._errors and raise_exception: - raise ValidationError(self.errors) + return data - return not bool(self._errors) + def save(self): + print("creating new allocation items!") + + data = self.validated_data + + print("data:") + print(data) + + items = data.get('items', []) + + print("items:") + print(items) + + build = self.context['build'] + + created_items = [] + + with transaction.atomic(): + for item in items: + bom_item = item['bom_item'] + stock_item = item['stock_item'] + quantity = item['quantity'] + output = item.get('output', None) + + # Create a new BuildItem to allocate stock + build_item = BuildItem.objects.create( + build=build, + bom_item=bom_item, + stock_item=stock_item, + quantity=quantity, + install_into=output + ) + + created_items.append(build_item) + + return created_items class BuildItemSerializer(InvenTreeModelSerializer): diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index b5e5406f69..dc44c3c6c8 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -252,34 +252,6 @@ class TestBuildViews(TestCase): self.assertIn(build.title, content) - def test_build_item_create(self): - """ Test the BuildItem creation view (ajax form) """ - - url = reverse('build-item-create') - - # Try without a part specified - response = self.client.get(url, {'build': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Try with an invalid build ID - response = self.client.get(url, {'build': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Try with a valid part specified - response = self.client.get(url, {'build': 1, 'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Try with an invalid part specified - response = self.client.get(url, {'build': 1, 'part': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_build_item_edit(self): - """ Test the BuildItem edit view (ajax form) """ - - # TODO - # url = reverse('build-item-edit') - pass - def test_build_output_complete(self): """ Test the build output completion form diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 9814dc83f7..7246e48d9a 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -25,7 +25,6 @@ build_urls = [ url('^edit/', views.BuildItemEdit.as_view(), name='build-item-edit'), url('^delete/', views.BuildItemDelete.as_view(), name='build-item-delete'), ])), - url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'), ])), url(r'^(?P\d+)/', include(build_detail_urls)), diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index da2d23cd0d..91460e5961 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -276,35 +276,30 @@ class POReceiveSerializer(serializers.Serializer): help_text=_('Select destination location for received items'), ) - def is_valid(self, raise_exception=False): + def validate(self, data): - super().is_valid(raise_exception) - - # Custom validation - data = self.validated_data + super().validate(data) items = data.get('items', []) if len(items) == 0: - self._errors['items'] = _('Line items must be provided') - else: - # Ensure barcodes are unique - unique_barcodes = set() + raise ValidationError({ + 'items': _('Line items must be provided') + }) - for item in items: - barcode = item.get('barcode', '') + # Ensure barcodes are unique + unique_barcodes = set() - if barcode: - if barcode in unique_barcodes: - self._errors['items'] = _('Supplied barcode values must be unique') - break - else: - unique_barcodes.add(barcode) + for item in items: + barcode = item.get('barcode', '') - if self._errors and raise_exception: - raise ValidationError(self.errors) + if barcode: + if barcode in unique_barcodes: + raise ValidationError(_('Supplied barcode values must be unique')) + else: + unique_barcodes.add(barcode) - return not bool(self._errors) + return data class Meta: fields = [ From 8f298f71efc046ef4fb032ac30d263b28982ff00 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 4 Oct 2021 23:49:31 +1100 Subject: [PATCH 11/48] Add detail API endpoint for BuildItem model --- InvenTree/build/api.py | 22 ++++++++++++++++------ InvenTree/build/serializers.py | 13 ------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index d53956cbfb..5735a66f62 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -98,7 +98,7 @@ class BuildList(generics.ListCreateAPIView): as some of the fields don't natively play nicely with DRF """ - queryset = super().get_queryset().prefetch_related('part') + queryset = super().get_queryset().select_related('part') queryset = BuildSerializer.annotate_queryset(queryset) @@ -229,6 +229,15 @@ class BuildAllocate(generics.CreateAPIView): return context +class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for detail view of a BuildItem object + """ + + queryset = BuildItem.objects.all() + serializer_class = BuildItemSerializer + + class BuildItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of BuildItem objects @@ -258,9 +267,9 @@ class BuildItemList(generics.ListCreateAPIView): query = BuildItem.objects.all() - query = query.select_related('stock_item') - query = query.prefetch_related('stock_item__part') - query = query.prefetch_related('stock_item__part__category') + query = query.select_related('stock_item__location') + query = query.select_related('stock_item__part') + query = query.select_related('stock_item__part__category') return query @@ -330,12 +339,13 @@ build_api_urls = [ # Attachments url(r'^attachment/', include([ url(r'^(?P\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'), - url('^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), + url(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), ])), # Build Items url(r'^item/', include([ - url('^.*$', BuildItemList.as_view(), name='api-build-item-list') + url(r'^(?P\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'), + url(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'), ])), # Build Detail diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index adbf7361eb..c15856d44e 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -256,22 +256,13 @@ class BuildAllocationSerializer(serializers.Serializer): return data def save(self): - print("creating new allocation items!") data = self.validated_data - print("data:") - print(data) - items = data.get('items', []) - print("items:") - print(items) - build = self.context['build'] - created_items = [] - with transaction.atomic(): for item in items: bom_item = item['bom_item'] @@ -288,10 +279,6 @@ class BuildAllocationSerializer(serializers.Serializer): install_into=output ) - created_items.append(build_item) - - return created_items - class BuildItemSerializer(InvenTreeModelSerializer): """ Serializes a BuildItem object """ From 8a90b9df6df538d5d6ac43926fa3c9fe5170f6b1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 00:14:31 +1100 Subject: [PATCH 12/48] Improve clean() function for BuildItem model - BuildItemDelete now handled by API forms - BuildItemEdit now handled by API forms --- InvenTree/build/models.py | 43 ++++++++++------- .../templates/build/delete_build_item.html | 14 ------ InvenTree/build/urls.py | 6 --- InvenTree/build/views.py | 47 ------------------- InvenTree/templates/js/translated/build.js | 24 ++++++---- 5 files changed, 41 insertions(+), 93 deletions(-) delete mode 100644 InvenTree/build/templates/build/delete_build_item.html diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 3224554156..4bd4d2e757 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -4,6 +4,7 @@ Build database model definitions # -*- coding: utf-8 -*- from __future__ import unicode_literals +import decimal import os from datetime import datetime @@ -1185,13 +1186,13 @@ class BuildItem(models.Model): def save(self, *args, **kwargs): - self.validate_unique() self.clean() super().save() def clean(self): - """ Check validity of the BuildItem model. + """ + Check validity of this BuildItem instance. The following checks are performed: - StockItem.part must be in the BOM of the Part object referenced by Build @@ -1202,8 +1203,6 @@ class BuildItem(models.Model): super().clean() - errors = {} - try: # If the 'part' is trackable, then the 'install_into' field must be set! @@ -1212,29 +1211,39 @@ class BuildItem(models.Model): # Allocated quantity cannot exceed available stock quantity if self.quantity > self.stock_item.quantity: - errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format( - n=normalize(self.quantity), - q=normalize(self.stock_item.quantity) - )] + + q = normalize(self.quantity) + a = normalize(self.stock_item.quantity) + + raise ValidationError({ + 'quantity': _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})') + }) # Allocated quantity cannot cause the stock item to be over-allocated - if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity: - errors['quantity'] = _('StockItem is over-allocated') + available = decimal.Decimal(self.stock_item.quantity) + allocated = decimal.Decimal(self.stock_item.allocation_count()) + quantity = decimal.Decimal(self.quantity) + + if available - allocated + quantity < quantity: + raise ValidationError({ + 'quantity': _('Stock item is over-allocated') + }) # Allocated quantity must be positive if self.quantity <= 0: - errors['quantity'] = _('Allocation quantity must be greater than zero') + raise ValidationError({ + 'quantity': _('Allocation quantity must be greater than zero'), + }) # Quantity must be 1 for serialized stock if self.stock_item.serialized and not self.quantity == 1: - errors['quantity'] = _('Quantity must be 1 for serialized stock') + raise ValidationError({ + 'quantity': _('Quantity must be 1 for serialized stock') + }) except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist): pass - if len(errors) > 0: - raise ValidationError(errors) - """ Attempt to find the "BomItem" which links this BuildItem to the build. @@ -1247,7 +1256,7 @@ class BuildItem(models.Model): """ A BomItem object has already been assigned. This is valid if: - a) It points to the same "part" as the referened build + a) It points to the same "part" as the referenced build b) Either: i) The sub_part points to the same part as the referenced StockItem ii) The BomItem allows variants and the part referenced by the StockItem @@ -1287,7 +1296,7 @@ class BuildItem(models.Model): if not bom_item_valid: raise ValidationError({ - 'stock_item': _("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name) + 'stock_item': _("Selected stock item not found in BOM") }) @transaction.atomic diff --git a/InvenTree/build/templates/build/delete_build_item.html b/InvenTree/build/templates/build/delete_build_item.html deleted file mode 100644 index d5cc285466..0000000000 --- a/InvenTree/build/templates/build/delete_build_item.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "modal_delete_form.html" %} -{% load i18n %} -{% load inventree_extras %} - -{% block pre_form_content %} -
-

- {% trans "Are you sure you want to unallocate this stock?" %} -

-

- {% trans "The selected stock will be unallocated from the build output" %} -

-
-{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 7246e48d9a..e7e6ddb5e2 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -20,12 +20,6 @@ build_detail_urls = [ ] build_urls = [ - url(r'item/', include([ - url(r'^(?P\d+)/', include([ - url('^edit/', views.BuildItemEdit.as_view(), name='build-item-edit'), - url('^delete/', views.BuildItemDelete.as_view(), name='build-item-delete'), - ])), - ])), url(r'^(?P\d+)/', include(build_detail_urls)), diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index dfa655f9a4..7c92fa1846 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -628,21 +628,6 @@ class BuildDelete(AjaxDeleteView): ajax_form_title = _('Delete Build Order') -class BuildItemDelete(AjaxDeleteView): - """ View to 'unallocate' a BuildItem. - Really we are deleting the BuildItem object from the database. - """ - - model = BuildItem - ajax_template_name = 'build/delete_build_item.html' - ajax_form_title = _('Unallocate Stock') - context_object_name = 'item' - - def get_data(self): - return { - 'danger': _('Removed parts from build allocation') - } - class BuildItemCreate(AjaxCreateView): """ @@ -859,35 +844,3 @@ class BuildItemCreate(AjaxCreateView): initials['quantity'] = quantity return initials - - -class BuildItemEdit(AjaxUpdateView): - """ View to edit a BuildItem object """ - - model = BuildItem - ajax_template_name = 'build/edit_build_item.html' - form_class = forms.EditBuildItemForm - ajax_form_title = _('Edit Stock Allocation') - - def get_data(self): - return { - 'info': _('Updated Build Item'), - } - - def get_form(self): - """ - Create form for editing a BuildItem. - - - Limit the StockItem options to items that match the part - """ - - form = super(BuildItemEdit, self).get_form() - - # Hide fields which we do not wish the user to edit - for field in ['build', 'stock_item']: - if form[field].value(): - form.fields[field].widget = HiddenInput() - - form.fields['install_into'].widget = HiddenInput() - - return form diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 14523e445a..27b189902e 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -596,11 +596,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { text = `{% trans "Quantity" %}: ${row.quantity}`; } - {% if build.status == BuildStatus.COMPLETE %} - url = `/stock/item/${row.pk}/`; - {% else %} - url = `/stock/item/${row.stock_item}/`; - {% endif %} + var pk = row.stock_item || row.pk; + + url = `/stock/item/${pk}/`; return renderLink(text, url); } @@ -647,15 +645,23 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { // Assign button callbacks to the newly created allocation buttons subTable.find('.button-allocation-edit').click(function() { var pk = $(this).attr('pk'); - launchModalForm(`/build/item/${pk}/edit/`, { - success: reloadTable, + + constructForm(`/api/build/item/${pk}/`, { + fields: { + quantity: {}, + }, + title: '{% trans "Edit Allocation" %}', + onSuccess: reloadTable, }); }); subTable.find('.button-allocation-delete').click(function() { var pk = $(this).attr('pk'); - launchModalForm(`/build/item/${pk}/delete/`, { - success: reloadTable, + + constructForm(`/api/build/item/${pk}/`, { + method: 'DELETE', + title: '{% trans "Remove Allocation" %}', + onSuccess: reloadTable, }); }); }, From 416ba51e22f70496f94845f5c45723563fd41d5a Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 00:39:22 +1100 Subject: [PATCH 13/48] Add auto-fill option to select2 inputs on API forms - WIll fill input if a single result is returned --- InvenTree/build/templates/build/detail.html | 2 +- InvenTree/templates/js/translated/build.js | 10 ++++-- InvenTree/templates/js/translated/forms.js | 32 ++++++++++++++++++- .../js/translated/model_renderers.js | 6 +++- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 421cac059c..cfe5e548c0 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -170,7 +170,7 @@ {% if build.active %}
{% endif %} {% endif %} -
+
+
+
+ +
+ +
+
+
+
+
{% else %}
{% trans "This Build Order does not have any associated untracked BOM items" %} @@ -419,9 +431,12 @@ function reloadTable() { {% if build.active %} $("#btn-auto-allocate").on('click', function() { + var bom_items = $("#allocation-table-untracked").bootstrapTable("getData"); + allocateStockToBuild( {{ build.pk }}, {{ build.part.pk }}, + bom_items, { success: function(data) { $('#allocation-table-untracked').bootstrapTable('refresh'); @@ -439,6 +454,22 @@ $('#btn-unallocate').on('click', function() { ); }); +$('#allocate-selected-items').click(function() { + + var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections"); + + allocateStockToBuild( + {{ build.pk }}, + {{ build.part.pk }}, + bom_items, + { + success: function(data) { + $('#allocation-table-untracked').bootstrapTable('refresh'); + } + } + ); +}); + $("#btn-order-parts").click(function() { launchModalForm("/order/purchase-order/order-parts/", { data: { diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index d1d53a9e9f..e7c6fab978 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -378,16 +378,24 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { // Primary key of the 'sub_part' var pk = $(this).attr('pk'); + // Extract BomItem information from this row + var row = $(table).bootstrapTable('getRowByUniqueId', pk); + + if (!row) { + console.log("WARNING: getRowByUniqueId returned null"); + return; + } + allocateStockToBuild( buildId, partId, + [ + row, + ], { success: function(data) { // TODO: Reload table }, - parts: [ - parseInt(pk), - ] } ); }); @@ -798,19 +806,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { * arguments: * - buildId: ID / PK value for the build * - partId: ID / PK value for the part being built + * - bom_items: A list of BomItem objects to be allocated * * options: * - outputId: ID / PK of the associated build output (or null for untracked items) - * - parts: List of ID values for filtering against specific sub parts */ -function allocateStockToBuild(build_id, part_id, options={}) { +function allocateStockToBuild(build_id, part_id, bom_items, options={}) { // ID of the associated "build output" (or null) var output_id = options.output || null; - // Extract list of BOM items (or empty list) - var sub_part_ids = options.parts || []; - var query_params = { part: part_id, sub_part_detail: true, @@ -884,179 +889,156 @@ function allocateStockToBuild(build_id, part_id, options={}) { return html; } - inventreeGet( - '{% url "api-bom-list" %}', - query_params, - { - success: function(response) { + var table_entries = ""; - // List of BOM item objects we are interested in - var bom_items = []; + for (var idx = 0; idx < bom_items.length; idx++) { + var bom_item = bom_items[idx]; - var table_entries = ""; + table_entries += renderBomItemRow(bom_item); + } - for (var idx = 0; idx < response.length; idx++) { - var item = response[idx]; + if (bom_items.length == 0) { - var sub_part_id = item.sub_part; + showAlertDialog( + '{% trans "Select Parts" %}', + '{% trans "You must select at least one part to allocate" %}', + ); - // Check if we are interested in this item - if (sub_part_ids.length > 0 && !sub_part_ids.includes(sub_part_id)) { - continue; + return; + } + + // Create table of parts + var html = ` + + + + + + + + + + + + ${table_entries} + +
{% trans "Part" %}{% trans "Stock Item" %}{% trans "Allocated" %}{% trans "Quantity" %}
+ `; + + + constructForm(`/api/build/${build_id}/allocate/`, { + method: 'POST', + fields: {}, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm stock allocation" %}', + title: '{% trans "Allocate Stock Items to Build Order" %}', + afterRender: function(fields, options) { + + // Initialize select2 fields + bom_items.forEach(function(bom_item) { + initializeRelatedField( + { + name: `items_stock_item_${bom_item.pk}`, + api_url: '{% url "api-stock-list" %}', + filters: { + part: bom_item.sub_part, + in_stock: true, + part_detail: false, + location_detail: true, + }, + model: 'stockitem', + required: true, + render_part_detail: false, + render_location_detail: true, + auto_fill: true, + noResults: function(query) { + return '{% trans "No matching stock items" %}'; + } + }, + null, + options, + ); + }); + + // Add button callbacks + $(options.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(options.modal).find(`#allocation_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, options) { + + // Extract elements from the form + var data = { + items: [] + }; + + var item_pk_values = []; + + bom_items.forEach(function(item) { + + var quantity = getFormFieldValue( + `items_quantity_${item.pk}`, + {}, + { + modal: options.modal, + }, + ); + + var stock_item = getFormFieldValue( + `items_stock_item_${item.pk}`, + {}, + { + modal: options.modal, } + ); - // TODO: Ignore items which are already fully allocated + if (quantity != null) { + data.items.push({ + bom_item: item.pk, + stock_item: stock_item, + quantity: quantity + }); - bom_items.push(item); - - // Add HTML - table_entries += renderBomItemRow(item); + item_pk_values.push(item.pk); } + }); - if (bom_items.length == 0) { + // Provide nested values + options.nested = { + "items": item_pk_values + }; - showAlertDialog( - '{% trans "Select Parts" %}', - '{% trans "You must select at least one part to allocate" %}', - ); - - return; - } - - // Create table of parts - var html = ` - - - - - - - - - - - - ${table_entries} - -
{% trans "Part" %}{% trans "Stock Item" %}{% trans "Allocated" %}{% trans "Quantity" %}
- `; - - constructForm(`/api/build/${build_id}/allocate/`, { + inventreePut( + options.url, + data, + { method: 'POST', - fields: {}, - preFormContent: html, - confirm: true, - confirmMessage: '{% trans "Confirm stock allocation" %}', - title: '{% trans "Allocate Stock Items to Build Order" %}', - afterRender: function(fields, options) { + success: function(response) { + // Hide the modal + $(options.modal).modal('hide'); - // Initialize select2 fields - bom_items.forEach(function(bom_item) { - initializeRelatedField( - { - name: `items_stock_item_${bom_item.pk}`, - api_url: '{% url "api-stock-list" %}', - filters: { - part: bom_item.sub_part, - in_stock: true, - part_detail: false, - location_detail: true, - }, - model: 'stockitem', - required: true, - render_part_detail: false, - render_location_detail: true, - auto_fill: true, - noResults: function(query) { - return '{% trans "No matching stock items" %}'; - } - }, - null, - options, - ); - }); - - // Add button callbacks - $(options.modal).find('.button-row-remove').click(function() { - var pk = $(this).attr('pk'); - - $(options.modal).find(`#allocation_row_${pk}`).remove(); - }); + if (options.success) { + options.success(response); + } }, - onSubmit: function(fields, options) { - - // Extract elements from the form - var data = { - items: [] - }; - - var item_pk_values = []; - - bom_items.forEach(function(item) { - - var quantity = getFormFieldValue( - `items_quantity_${item.pk}`, - {}, - { - modal: options.modal, - }, - ); - - var stock_item = getFormFieldValue( - `items_stock_item_${item.pk}`, - {}, - { - modal: options.modal, - } - ); - - if (quantity != null) { - data.items.push({ - bom_item: item.pk, - stock_item: stock_item, - quantity: quantity - }); - - item_pk_values.push(item.pk); - } - }); - - // Provide nested values - options.nested = { - "items": item_pk_values - }; - - inventreePut( - options.url, - data, - { - method: 'POST', - success: function(response) { - // Hide the modal - $(options.modal).modal('hide'); - - if (options.success) { - options.success(response); - } - }, - error: function(xhr) { - switch (xhr.status) { - case 400: - handleFormErrors(xhr.responseJSON, fields, options); - break; - default: - $(options.modal).modal('hide'); - showApiError(xhr); - break; - } - } - } - ); - }, - }); - } - } - ); + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, options); + break; + default: + $(options.modal).modal('hide'); + showApiError(xhr); + break; + } + } + } + ); + }, + }); } From 6e31a8111b296b15219b39aaf3f0ad349c0de37b Mon Sep 17 00:00:00 2001 From: eeintech Date: Mon, 4 Oct 2021 15:57:07 -0400 Subject: [PATCH 18/48] Fix plural name for Companies in Admin interface --- InvenTree/company/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index b0bb8caaa5..ebe61a74b0 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -94,6 +94,7 @@ class Company(models.Model): constraints = [ UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair') ] + verbose_name_plural = "Companies" name = models.CharField(max_length=100, blank=False, help_text=_('Company name'), From a735a3e15cfc1111809d4bea4c9c1b167a90a5d7 Mon Sep 17 00:00:00 2001 From: eeintech Date: Mon, 4 Oct 2021 16:41:41 -0400 Subject: [PATCH 19/48] Added migration file --- .../migrations/0041_alter_company_options.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 InvenTree/company/migrations/0041_alter_company_options.py diff --git a/InvenTree/company/migrations/0041_alter_company_options.py b/InvenTree/company/migrations/0041_alter_company_options.py new file mode 100644 index 0000000000..40849eed1d --- /dev/null +++ b/InvenTree/company/migrations/0041_alter_company_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.5 on 2021-10-04 20:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0040_alter_company_currency'), + ] + + operations = [ + migrations.AlterModelOptions( + name='company', + options={'ordering': ['name'], 'verbose_name_plural': 'Companies'}, + ), + ] From 0c04bfaa857e2ea8795992fb4b341ae5655e4c4e Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 08:05:26 +1100 Subject: [PATCH 20/48] Move "rebuild_thumbnails" to being a management command - Does not run every time the server reboots - Has to be called manually - Normally does not need to be run and is a long-running process! --- .../management/commands/rebuild_thumbnails.py | 70 +++++++++++++++++++ InvenTree/company/apps.py | 27 +------ InvenTree/part/apps.py | 32 --------- tasks.py | 17 +++-- 4 files changed, 83 insertions(+), 63 deletions(-) create mode 100644 InvenTree/InvenTree/management/commands/rebuild_thumbnails.py diff --git a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py new file mode 100644 index 0000000000..243d609863 --- /dev/null +++ b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py @@ -0,0 +1,70 @@ +""" +Custom management command to rebuild thumbnail images + +- May be required after importing a new dataset, for example +""" + +import os +import logging + +from PIL import UnidentifiedImageError + +from django.core.management.base import BaseCommand +from django.conf import settings +from django.db.utils import OperationalError, ProgrammingError + +from company.models import Company +from part.models import Part + + +logger = logging.getLogger("inventree-thumbnails") + + +class Command(BaseCommand): + """ + Rebuild all thumbnail images + """ + + def rebuild_thumbnail(self, model): + """ + Rebuild the thumbnail specified by the "image" field of the provided model + """ + + if not model.image: + return + + img = model.image + url = img.thumbnail.name + loc = os.path.join(settings.MEDIA_ROOT, url) + + if not os.path.exists(loc): + logger.info(f"Generating thumbnail image for '{img}'") + + try: + model.image.render_variations(replace=False) + except FileNotFoundError: + logger.error(f"ERROR: Image file '{img}' is missing") + except UnidentifiedImageError: + logger.error(f"ERROR: Image file '{img}' is not a valid image") + + def handle(self, *args, **kwargs): + + logger.setLevel(logging.INFO) + + logger.info("Rebuilding Part thumbnails") + + for part in Part.objects.exclude(image=None): + try: + self.rebuild_thumbnail(part) + except (OperationalError, ProgrammingError): + logger.error("ERROR: Database read error.") + break + + logger.info("Rebuilding Company thumbnails") + + for company in Company.objects.exclude(image=None): + try: + self.rebuild_thumbnail(company) + except (OperationalError, ProgrammingError): + logger.error("ERROR: abase read error.") + break diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 76798c5ad4..497193237d 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -23,29 +23,4 @@ class CompanyConfig(AppConfig): This function is called whenever the Company app is loaded. """ - if canAppAccessDatabase(): - self.generate_company_thumbs() - - def generate_company_thumbs(self): - - from .models import Company - - logger.debug("Checking Company image thumbnails") - - try: - for company in Company.objects.all(): - if company.image: - url = company.image.thumbnail.name - loc = os.path.join(settings.MEDIA_ROOT, url) - - if not os.path.exists(loc): - logger.info("InvenTree: Generating thumbnail for Company '{c}'".format(c=company.name)) - try: - company.image.render_variations(replace=False) - except FileNotFoundError: - logger.warning(f"Image file '{company.image}' missing") - except UnidentifiedImageError: - logger.warning(f"Image file '{company.image}' is invalid") - except (OperationalError, ProgrammingError): - # Getting here probably meant the database was in test mode - pass + pass diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index 0c57e2c1ab..ed423da9bd 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -24,40 +24,8 @@ class PartConfig(AppConfig): """ if canAppAccessDatabase(): - self.generate_part_thumbnails() self.update_trackable_status() - def generate_part_thumbnails(self): - """ - Generate thumbnail images for any Part that does not have one. - This function exists mainly for legacy support, - as any *new* image uploaded will have a thumbnail generated automatically. - """ - - from .models import Part - - logger.debug("InvenTree: Checking Part image thumbnails") - - try: - # Only check parts which have images - for part in Part.objects.exclude(image=None): - if part.image: - url = part.image.thumbnail.name - loc = os.path.join(settings.MEDIA_ROOT, url) - - if not os.path.exists(loc): - logger.info("InvenTree: Generating thumbnail for Part '{p}'".format(p=part.name)) - try: - part.image.render_variations(replace=False) - except FileNotFoundError: - logger.warning(f"Image file '{part.image}' missing") - pass - except UnidentifiedImageError: - logger.warning(f"Image file '{part.image}' is invalid") - except (OperationalError, ProgrammingError): - # Exception if the database has not been migrated yet - pass - def update_trackable_status(self): """ Check for any instances where a trackable part is used in the BOM diff --git a/tasks.py b/tasks.py index 1abbf23bc6..59fa83e56b 100644 --- a/tasks.py +++ b/tasks.py @@ -127,13 +127,20 @@ def worker(c): @task -def rebuild(c): +def rebuild_models(c): """ Rebuild database models with MPTT structures """ - manage(c, "rebuild_models") + manage(c, "rebuild_models", pty=True) +@task +def rebuild_thumbnails(c): + """ + Rebuild missing image thumbnails + """ + + manage(c, "rebuild_thumbnails", pty=True) @task def clean_settings(c): @@ -143,7 +150,7 @@ def clean_settings(c): manage(c, "clean_settings") -@task(post=[rebuild]) +@task(post=[rebuild_models, rebuild_thumbnails]) def migrate(c): """ Performs database migrations. @@ -341,7 +348,7 @@ def export_records(c, filename='data.json'): print("Data export completed") -@task(help={'filename': 'Input filename'}, post=[rebuild]) +@task(help={'filename': 'Input filename'}, post=[rebuild_models, rebuild_thumbnails]) def import_records(c, filename='data.json'): """ Import database records from a file @@ -399,7 +406,7 @@ def delete_data(c, force=False): manage(c, 'flush') -@task(post=[rebuild]) +@task(post=[rebuild_models, rebuild_thumbnails]) def import_fixtures(c): """ Import fixture data into the database. From ae0efe73d17fbd87ae1085891d35d86670281a50 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 08:25:10 +1100 Subject: [PATCH 21/48] Further improvements to build allocation form - Auto-allocation button ignores outputs which are complete - StockItem API allows filtering by BomItem - Quantity inputs are now auto-filled - Display progress bar in the modal form --- .../management/commands/rebuild_thumbnails.py | 2 +- InvenTree/InvenTree/version.py | 6 +- InvenTree/build/models.py | 5 +- InvenTree/build/templates/build/detail.html | 34 ++++++++--- InvenTree/company/apps.py | 12 ---- InvenTree/part/apps.py | 6 +- InvenTree/part/models.py | 17 ++++++ InvenTree/stock/api.py | 33 ++++++++--- InvenTree/templates/js/translated/build.js | 56 +++++++++++++------ 9 files changed, 117 insertions(+), 54 deletions(-) diff --git a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py index 243d609863..07e700a1cf 100644 --- a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py +++ b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py @@ -23,7 +23,7 @@ logger = logging.getLogger("inventree-thumbnails") class Command(BaseCommand): """ Rebuild all thumbnail images - """ + """ def rebuild_thumbnail(self, model): """ diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index f309f85e66..1d9423371f 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,11 +10,15 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" -INVENTREE_API_VERSION = 12 +INVENTREE_API_VERSION = 13 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v13 -> 2021-10-05 + - Adds API endpoint to allocate stock items against a BuildOrder + - Updates StockItem API with improved filtering against BomItem data + v12 -> 2021-09-07 - Adds API endpoint to receive stock items against a PurchaseOrder diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index feddd01112..9a7b40b52f 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -9,8 +9,9 @@ import decimal import os from datetime import datetime -from django.contrib.auth.models import User from django.utils.translation import ugettext_lazy as _ + +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.urls import reverse @@ -1055,8 +1056,10 @@ class BuildItem(models.Model): Attributes: build: Link to a Build object + bom_item: Link to a BomItem object (may or may not point to the same part as the build) stock_item: Link to a StockItem object quantity: Number of units allocated + install_into: Destination stock item (or None) """ @staticmethod diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index c219a64db3..5e7f3d7c5f 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -433,16 +433,32 @@ $("#btn-auto-allocate").on('click', function() { var bom_items = $("#allocation-table-untracked").bootstrapTable("getData"); - allocateStockToBuild( - {{ build.pk }}, - {{ build.part.pk }}, - bom_items, - { - success: function(data) { - $('#allocation-table-untracked').bootstrapTable('refresh'); - } + var incomplete_bom_items = []; + + bom_items.forEach(function(bom_item) { + if (bom_item.required > bom_item.allocated) { + incomplete_bom_items.push(bom_item); } - ); + }); + + if (incomplete_bom_items.length == 0) { + showAlertDialog( + '{% trans "Allocation Complete" %}', + '{% trans "All untracked stock items have been allocated" %}', + ); + } else { + + allocateStockToBuild( + {{ build.pk }}, + {{ build.part.pk }}, + incomplete_bom_items, + { + success: function(data) { + $('#allocation-table-untracked').bootstrapTable('refresh'); + } + } + ); + } }); $('#btn-unallocate').on('click', function() { diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 497193237d..41371dd739 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -1,18 +1,6 @@ from __future__ import unicode_literals -import os -import logging - -from PIL import UnidentifiedImageError - from django.apps import AppConfig -from django.db.utils import OperationalError, ProgrammingError -from django.conf import settings - -from InvenTree.ready import canAppAccessDatabase - - -logger = logging.getLogger("inventree") class CompanyConfig(AppConfig): diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index ed423da9bd..49a9f2f90c 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -1,13 +1,9 @@ from __future__ import unicode_literals -import os import logging from django.db.utils import OperationalError, ProgrammingError from django.apps import AppConfig -from django.conf import settings - -from PIL import UnidentifiedImageError from InvenTree.ready import canAppAccessDatabase @@ -40,7 +36,7 @@ class PartConfig(AppConfig): items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True) for item in items: - print(f"Marking part '{item.part.name}' as trackable") + logger.info(f"Marking part '{item.part.name}' as trackable") item.part.trackable = True item.part.clean() item.part.save() diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index d7ad577081..18a53f5a79 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2329,6 +2329,23 @@ class BomItem(models.Model): def get_api_url(): return reverse('api-bom-list') + def get_stock_filter(self): + """ + Return a queryset filter for selecting StockItems which match this BomItem + + - If allow_variants is True, allow all part variants + + """ + + # Target part + part = self.sub_part + + if self.allow_variants: + variants = part.get_descendants(include_self=True) + return Q(part__in=[v.pk for v in variants]) + else: + return Q(part=part) + def save(self, *args, **kwargs): self.clean() diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index eaa65dd763..e03d821bae 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -2,11 +2,18 @@ JSON API for the Stock app """ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from decimal import Decimal, InvalidOperation +from datetime import datetime, timedelta + +from django.utils.translation import ugettext_lazy as _ + from django.conf.urls import url, include from django.urls import reverse from django.http import JsonResponse from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ from rest_framework import status from rest_framework.serializers import ValidationError @@ -22,7 +29,7 @@ from .models import StockItemTracking from .models import StockItemAttachment from .models import StockItemTestResult -from part.models import Part, PartCategory +from part.models import BomItem, Part, PartCategory from part.serializers import PartBriefSerializer from company.models import Company, SupplierPart @@ -45,10 +52,6 @@ from InvenTree.helpers import str2bool, isNull from InvenTree.api import AttachmentMixin from InvenTree.filters import InvenTreeOrderingFilter -from decimal import Decimal, InvalidOperation - -from datetime import datetime, timedelta - class StockCategoryTree(TreeSerializer): title = _('Stock') @@ -670,14 +673,14 @@ class StockList(generics.ListCreateAPIView): return queryset def filter_queryset(self, queryset): + """ + Custom filtering for the StockItem queryset + """ params = self.request.query_params queryset = super().filter_queryset(queryset) - # Perform basic filtering: - # Note: We do not let DRF filter here, it be slow AF - supplier_part = params.get('supplier_part', None) if supplier_part: @@ -843,6 +846,18 @@ class StockList(generics.ListCreateAPIView): except (ValueError, PartCategory.DoesNotExist): raise ValidationError({"category": "Invalid category id specified"}) + # Does the client wish to filter by BomItem + bom_item_id = params.get('bom_item', None) + + if bom_item_id is not None: + try: + bom_item = BomItem.objects.get(pk=bom_item_id) + + queryset = queryset.filter(bom_item.get_stock_filter()) + + except (ValueError, BomItem.DoesNotExist): + pass + # Filter by StockItem status status = params.get('status', None) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index e7c6fab978..e72d4a2387 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -345,18 +345,26 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { function requiredQuantity(row) { // Return the requied quantity for a given row + var quantity = 0; + if (output) { // "Tracked" parts are calculated against individual build outputs - return row.quantity * output.quantity; + quantity = row.quantity * output.quantity; } else { // "Untracked" parts are specified against the build itself - return row.quantity * buildInfo.quantity; + quantity = row.quantity * buildInfo.quantity; } + + // Store the required quantity in the row data + row.required = quantity; + + return quantity; } function sumAllocations(row) { // Calculat total allocations for a given row if (!row.allocations) { + row.allocated = 0; return 0; } @@ -366,6 +374,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { quantity += item.quantity; }); + row.allocated = quantity; + return quantity; } @@ -394,7 +404,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { ], { success: function(data) { - // TODO: Reload table + $(table).bootstrapTable('refresh'); }, } ); @@ -854,6 +864,11 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { } ); + var allocated_display = makeProgressBar( + bom_item.allocated, + bom_item.required, + ); + var stock_input = constructField( `items_stock_item_${pk}`, { @@ -872,11 +887,12 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { ${thumb} ${sub_part.full_name} + + ${allocated_display} + ${stock_input} - - ${quantity_input} @@ -894,7 +910,15 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { for (var idx = 0; idx < bom_items.length; idx++) { var bom_item = bom_items[idx]; - table_entries += renderBomItemRow(bom_item); + var required = bom_item.required || 0; + var allocated = bom_item.allocated || 0; + var remaining = required - allocated; + + if (remaining < 0) { + remaining = 0; + } + + table_entries += renderBomItemRow(bom_item, remaining); } if (bom_items.length == 0) { @@ -913,8 +937,8 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { {% trans "Part" %} - {% trans "Stock Item" %} {% trans "Allocated" %} + {% trans "Stock Item" %} {% trans "Quantity" %} @@ -942,7 +966,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { name: `items_stock_item_${bom_item.pk}`, api_url: '{% url "api-stock-list" %}', filters: { - part: bom_item.sub_part, + bom_item: bom_item.pk, in_stock: true, part_detail: false, location_detail: true, @@ -968,7 +992,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { $(options.modal).find(`#allocation_row_${pk}`).remove(); }); }, - onSubmit: function(fields, options) { + onSubmit: function(fields, opts) { // Extract elements from the form var data = { @@ -983,7 +1007,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { `items_quantity_${item.pk}`, {}, { - modal: options.modal, + modal: opts.modal, }, ); @@ -991,7 +1015,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { `items_stock_item_${item.pk}`, {}, { - modal: options.modal, + modal: opts.modal, } ); @@ -1007,18 +1031,18 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { }); // Provide nested values - options.nested = { + opts.nested = { "items": item_pk_values }; inventreePut( - options.url, + opts.url, data, { method: 'POST', success: function(response) { // Hide the modal - $(options.modal).modal('hide'); + $(opts.modal).modal('hide'); if (options.success) { options.success(response); @@ -1027,10 +1051,10 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { error: function(xhr) { switch (xhr.status) { case 400: - handleFormErrors(xhr.responseJSON, fields, options); + handleFormErrors(xhr.responseJSON, fields, opts); break; default: - $(options.modal).modal('hide'); + $(opts.modal).modal('hide'); showApiError(xhr); break; } From f6cf84f912f41a52b7033684e75acc8ff556d8da Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 08:48:50 +1100 Subject: [PATCH 22/48] Form now works for "tracked" build outputs --- InvenTree/templates/js/translated/build.js | 27 +++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index e72d4a2387..d1226259cd 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -103,6 +103,7 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { */ var buildId = buildInfo.pk; + var partId = buildInfo.part; var outputId = 'untracked'; @@ -121,11 +122,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { var html = `
`; - // "Auto" allocation only works for untracked stock items - if (!output && lines > 0) { + if (lines > 0) { html += makeIconButton( - 'fa-magic icon-blue', 'button-output-auto', outputId, - '{% trans "Auto-allocate stock items to this output" %}', + 'fa-sign-in-alt icon-blue', 'button-output-auto', outputId, + '{% trans "Allocate stock items to this build output" %}', ); } @@ -137,7 +137,6 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { ); } - if (output) { // Add a button to "complete" the particular build output @@ -164,7 +163,21 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { // Add callbacks for the buttons $(panel).find(`#button-output-auto-${outputId}`).click(function() { + + var bom_items = $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('getData'); + // Launch modal dialog to perform auto-allocation + allocateStockToBuild( + buildId, + partId, + bom_items, + { + output: outputId, + success: reloadTable, + } + ); + + return; launchModalForm(`/build/${buildId}/auto-allocate/`, { data: { @@ -406,6 +419,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { success: function(data) { $(table).bootstrapTable('refresh'); }, + output: output == null ? null : output.pk, } ); }); @@ -1023,7 +1037,8 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { data.items.push({ bom_item: item.pk, stock_item: stock_item, - quantity: quantity + quantity: quantity, + output: output_id, }); item_pk_values.push(item.pk); From 76668b0d54fb36802d3efc1110439baaa54d0856 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 09:21:33 +1100 Subject: [PATCH 23/48] Remove unused code (views / forms) --- InvenTree/build/forms.py | 21 +- .../templates/build/create_build_item.html | 20 -- InvenTree/build/views.py | 223 +----------------- InvenTree/templates/js/translated/build.js | 9 - 4 files changed, 4 insertions(+), 269 deletions(-) delete mode 100644 InvenTree/build/templates/build/create_build_item.html diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index 47c9b8612c..b3f6cd92de 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -15,7 +15,7 @@ from InvenTree.fields import DatePickerFormField from InvenTree.status_codes import StockStatus -from .models import Build, BuildItem +from .models import Build from stock.models import StockLocation, StockItem @@ -244,22 +244,3 @@ class CancelBuildForm(HelperForm): fields = [ 'confirm_cancel' ] - - -class EditBuildItemForm(HelperForm): - """ - Form for creating (or editing) a BuildItem object. - """ - - quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'), help_text=_('Select quantity of stock to allocate')) - - part_id = forms.IntegerField(required=False, widget=forms.HiddenInput()) - - class Meta: - model = BuildItem - fields = [ - 'build', - 'stock_item', - 'quantity', - 'install_into', - ] diff --git a/InvenTree/build/templates/build/create_build_item.html b/InvenTree/build/templates/build/create_build_item.html deleted file mode 100644 index cc23bd49a9..0000000000 --- a/InvenTree/build/templates/build/create_build_item.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} -
-

- {% trans "Select a stock item to allocate to the selected build output" %} -

- {% if output %} -

- {% blocktrans %}The allocated stock will be installed into the following build output:
{{output}}{% endblocktrans %} -

- {% endif %} -
-{% if no_stock %} - -{% endif %} -{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 82f9f3586b..702b3b3596 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -11,13 +11,13 @@ from django.views.generic import DetailView, ListView from django.forms import HiddenInput from part.models import Part -from .models import Build, BuildItem +from .models import Build from . import forms from stock.models import StockLocation, StockItem -from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView +from InvenTree.views import AjaxUpdateView, AjaxDeleteView from InvenTree.views import InvenTreeRoleMixin -from InvenTree.helpers import str2bool, extract_serial_numbers, normalize, isNull +from InvenTree.helpers import str2bool, extract_serial_numbers, isNull from InvenTree.status_codes import BuildStatus, StockStatus @@ -565,220 +565,3 @@ class BuildDelete(AjaxDeleteView): model = Build ajax_template_name = 'build/delete_build.html' ajax_form_title = _('Delete Build Order') - - -class BuildItemCreate(AjaxCreateView): - """ - View for allocating a StockItem to a build output. - """ - - model = BuildItem - form_class = forms.EditBuildItemForm - ajax_template_name = 'build/create_build_item.html' - ajax_form_title = _('Allocate stock to build output') - - # The output StockItem against which the allocation is being made - output = None - - # The "part" which is being allocated to the output - part = None - - available_stock = None - - def get_context_data(self): - """ - Provide context data to the template which renders the form. - """ - - ctx = super().get_context_data() - - if self.part: - ctx['part'] = self.part - - if self.output: - ctx['output'] = self.output - - if self.available_stock: - ctx['stock'] = self.available_stock - else: - ctx['no_stock'] = True - - return ctx - - def validate(self, build_item, form, **kwargs): - """ - Extra validation steps as required - """ - - data = form.cleaned_data - - stock_item = data.get('stock_item', None) - quantity = data.get('quantity', None) - - if stock_item: - # Stock item must actually be in stock! - if not stock_item.in_stock: - form.add_error('stock_item', _('Item must be currently in stock')) - - # Check that there are enough items available - if quantity is not None: - available = stock_item.unallocated_quantity() - if quantity > available: - form.add_error('stock_item', _('Stock item is over-allocated')) - form.add_error('quantity', _('Available') + ': ' + str(normalize(available))) - else: - form.add_error('stock_item', _('Stock item must be selected')) - - def get_form(self): - """ Create Form for making / editing new Part object """ - - form = super(AjaxCreateView, self).get_form() - - self.build = None - self.part = None - self.output = None - - # If the Build object is specified, hide the input field. - # We do not want the users to be able to move a BuildItem to a different build - build_id = form['build'].value() - - if build_id is not None: - """ - If the build has been provided, hide the widget to change the build selection. - Additionally, update the allowable selections for other fields. - """ - form.fields['build'].widget = HiddenInput() - form.fields['install_into'].queryset = StockItem.objects.filter(build=build_id, is_building=True) - self.build = Build.objects.get(pk=build_id) - else: - """ - Build has *not* been selected - """ - pass - - # If the sub_part is supplied, limit to matching stock items - part_id = form['part_id'].value() - - if part_id: - try: - self.part = Part.objects.get(pk=part_id) - - except (ValueError, Part.DoesNotExist): - pass - - # If the output stock item is specified, hide the input field - output_id = form['install_into'].value() - - if output_id is not None: - - try: - self.output = StockItem.objects.get(pk=output_id) - form.fields['install_into'].widget = HiddenInput() - except (ValueError, StockItem.DoesNotExist): - pass - - else: - # If the output is not specified, but we know that the part is non-trackable, hide the install_into field - if self.part and not self.part.trackable: - form.fields['install_into'].widget = HiddenInput() - - if self.build and self.part: - available_items = self.build.availableStockItems(self.part, self.output) - - form.fields['stock_item'].queryset = available_items - - self.available_stock = form.fields['stock_item'].queryset.all() - - # If there is only a single stockitem available, select it! - if len(self.available_stock) == 1: - form.fields['stock_item'].initial = self.available_stock[0].pk - - return form - - def get_initial(self): - """ Provide initial data for BomItem. Look for the folllowing in the GET data: - - - build: pk of the Build object - - part: pk of the Part object which we are assigning - - output: pk of the StockItem object into which the allocated stock will be installed - """ - - initials = super(AjaxCreateView, self).get_initial().copy() - - build_id = self.get_param('build') - part_id = self.get_param('part') - output_id = self.get_param('install_into') - - # Reference to a Part object - part = None - - # Reference to a StockItem object - item = None - - # Reference to a Build object - build = None - - # Reference to a StockItem object - output = None - - if part_id: - try: - part = Part.objects.get(pk=part_id) - initials['part_id'] = part.pk - except Part.DoesNotExist: - pass - - if build_id: - try: - build = Build.objects.get(pk=build_id) - initials['build'] = build - except Build.DoesNotExist: - pass - - # If the output has been specified - if output_id: - try: - output = StockItem.objects.get(pk=output_id) - initials['install_into'] = output - except (ValueError, StockItem.DoesNotExist): - pass - - # Work out how much stock is required - if build and part: - required_quantity = build.unallocatedQuantity(part, output) - else: - required_quantity = None - - quantity = self.request.GET.get('quantity', None) - - if quantity is not None: - quantity = float(quantity) - elif required_quantity is not None: - quantity = required_quantity - - item_id = self.get_param('item') - - # If the request specifies a particular StockItem - if item_id: - try: - item = StockItem.objects.get(pk=item_id) - except (ValueError, StockItem.DoesNotExist): - pass - - # If a StockItem is not selected, try to auto-select one - if item is None and part is not None: - items = StockItem.objects.filter(part=part) - if items.count() == 1: - item = items.first() - - # Finally, if a StockItem is selected, ensure the quantity is not too much - if item is not None: - if quantity is None: - quantity = item.unallocated_quantity() - else: - quantity = min(quantity, item.unallocated_quantity()) - - if quantity is not None: - initials['quantity'] = quantity - - return initials diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index d1226259cd..325cb962a2 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -176,15 +176,6 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { success: reloadTable, } ); - - return; - launchModalForm(`/build/${buildId}/auto-allocate/`, - { - data: { - }, - success: reloadTable, - } - ); }); $(panel).find(`#button-output-complete-${outputId}`).click(function() { From 563deb5ffad84d23cda49b7c3d54dafea7912c88 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 10:38:18 +1100 Subject: [PATCH 24/48] Add ability to specify "source location" for stock allocations - Defaults to build.take_from - User-selectable at run-time - Selected value affects select2 query --- InvenTree/build/templates/build/detail.html | 16 ++--- InvenTree/stock/api.py | 2 +- InvenTree/templates/js/translated/build.js | 66 ++++++++++++++++++++- InvenTree/templates/js/translated/forms.js | 24 +++++++- 4 files changed, 94 insertions(+), 14 deletions(-) diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 5e7f3d7c5f..2f707dcc40 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -317,6 +317,9 @@ var buildInfo = { quantity: {{ build.quantity }}, completed: {{ build.completed }}, part: {{ build.part.pk }}, + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} }; {% for item in build.incomplete_outputs %} @@ -412,13 +415,6 @@ $('#edit-notes').click(function() { }); }); -var buildInfo = { - pk: {{ build.pk }}, - quantity: {{ build.quantity }}, - completed: {{ build.completed }}, - part: {{ build.part.pk }}, -}; - {% if build.has_untracked_bom_items %} // Load allocation table for un-tracked parts loadBuildOutputAllocationTable(buildInfo, null); @@ -453,6 +449,9 @@ $("#btn-auto-allocate").on('click', function() { {{ build.part.pk }}, incomplete_bom_items, { + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} success: function(data) { $('#allocation-table-untracked').bootstrapTable('refresh'); } @@ -479,6 +478,9 @@ $('#allocate-selected-items').click(function() { {{ build.part.pk }}, bom_items, { + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} success: function(data) { $('#allocation-table-untracked').bootstrapTable('refresh'); } diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index e03d821bae..27c2426d53 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -821,7 +821,7 @@ class StockList(generics.ListCreateAPIView): if loc_id is not None: # Filter by 'null' location (i.e. top-level items) - if isNull(loc_id): + if isNull(loc_id) and not cascade: queryset = queryset.filter(location=None) else: try: diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 325cb962a2..4e385d9f9a 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -172,6 +172,7 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { partId, bom_items, { + source_location: buildInfo.source_location, output: outputId, success: reloadTable, } @@ -407,6 +408,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { row, ], { + source_location: buildInfo.source_location, success: function(data) { $(table).bootstrapTable('refresh'); }, @@ -824,7 +826,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { * - bom_items: A list of BomItem objects to be allocated * * options: - * - outputId: ID / PK of the associated build output (or null for untracked items) + * - output: ID / PK of the associated build output (or null for untracked items) + * - source_location: ID / PK of the top-level StockLocation to take parts from (or null) */ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { @@ -837,6 +840,8 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { sub_part_trackable: output_id != null }; + var source_location = options.source_location; + function renderBomItemRow(bom_item, quantity) { var pk = bom_item.pk; @@ -936,8 +941,22 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { return; } + var html = ``; + + // Render a "take from" input + html += constructField( + 'take_from', + { + type: 'related field', + label: '{% trans "Source Location" %}', + help_text: '{% trans "Select source location (leave blank to take from all locations)" %}', + required: false, + }, + {}, + ); + // Create table of parts - var html = ` + html += ` @@ -964,7 +983,26 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { title: '{% trans "Allocate Stock Items to Build Order" %}', afterRender: function(fields, options) { - // Initialize select2 fields + var take_from_field = { + name: 'take_from', + model: 'stocklocation', + api_url: '{% url "api-location-list" %}', + required: false, + type: 'related field', + value: source_location, + noResults: function(query) { + return '{% trans "No matching stock locations" %}'; + }, + }; + + // Initialize "take from" field + initializeRelatedField( + take_from_field, + null, + options, + ); + + // Initialize stock item fields bom_items.forEach(function(bom_item) { initializeRelatedField( { @@ -981,6 +1019,21 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { render_part_detail: false, render_location_detail: true, auto_fill: true, + adjustFilters: function(filters) { + // Restrict query to the selected location + var location = getFormFieldValue( + 'take_from', + {}, + { + modal: options.modal, + } + ); + + filters.location = location; + filters.cascade = true; + + return filters; + }, noResults: function(query) { return '{% trans "No matching stock items" %}'; } @@ -990,6 +1043,13 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { ); }); + // Add callback to "clear" button for take_from field + addClearCallback( + "take_from", + take_from_field, + options, + ); + // Add button callbacks $(options.modal).find('.button-row-remove').click(function() { var pk = $(this).attr('pk'); diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index ee30c4ea1c..2d8a563550 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -728,10 +728,17 @@ function updateFieldValues(fields, options) { } } - +/* + * Update the value of a named field + */ function updateFieldValue(name, value, field, options) { var el = $(options.modal).find(`#id_${name}`); + if (!el) { + console.log(`WARNING: updateFieldValue could not find field '${name}'`); + return; + } + switch (field.type) { case 'boolean': el.prop('checked', value); @@ -1105,7 +1112,14 @@ function addClearCallbacks(fields, options) { function addClearCallback(name, field, options) { - $(options.modal).find(`#clear_${name}`).click(function() { + var el = $(options.modal).find(`#clear_${name}`); + + if (!el) { + console.log(`WARNING: addClearCallback could not find field '${name}'`); + return; + } + + el.click(function() { updateFieldValue(name, null, field, options); }); } @@ -1332,6 +1346,11 @@ function initializeRelatedField(field, fields, options) { query.search = params.term; query.offset = offset; query.limit = pageSize; + + // Allow custom run-time filter augmentation + if ("adjustFilters" in field) { + query = field.adjustFilters(query); + } return query; }, @@ -1453,7 +1472,6 @@ function initializeRelatedField(field, fields, options) { } } }); - } } From e7d78db87be6546d5a96f07de51261a32f376ba2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 10:53:11 +1100 Subject: [PATCH 25/48] Fixes for unit tests --- InvenTree/part/models.py | 8 ++++++-- InvenTree/part/test_api.py | 2 +- InvenTree/part/test_bom_item.py | 10 ++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 18a53f5a79..8c43a623a0 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -4,6 +4,7 @@ Part database model definitions # -*- coding: utf-8 -*- from __future__ import unicode_literals +import decimal import os import logging @@ -1530,10 +1531,13 @@ class Part(MPTTModel): for item in self.get_bom_items().all().select_related('sub_part'): if item.sub_part.pk == self.pk: - print("Warning: Item contains itself in BOM") + logger.warning(f"WARNING: BomItem ID {item.pk} contains itself in BOM") continue - prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal, purchase=purchase) + q = decimal.Decimal(quantity) + i = decimal.Decimal(item.quantity) + + prices = item.sub_part.get_price_range(q * i, internal=internal, purchase=purchase) if prices is None: continue diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 660b573e33..ac9d6bdf45 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -277,7 +277,7 @@ class PartAPITest(InvenTreeAPITestCase): """ There should be 4 BomItem objects in the database """ url = reverse('api-bom-list') response = self.client.get(url, format='json') - self.assertEqual(len(response.data), 4) + self.assertEqual(len(response.data), 5) def test_get_bom_detail(self): # Get the detail for a single BomItem diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 66897b28fc..be9740d128 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -120,7 +120,13 @@ class BomItemTest(TestCase): def test_pricing(self): self.bob.get_price(1) - self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(84.5), Decimal(89.5))) + self.assertEqual( + self.bob.get_bom_price_range(1, internal=True), + (Decimal(29.5), Decimal(89.5)) + ) # remove internal price for R_2K2_0805 self.r1.internal_price_breaks.delete() - self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(82.5), Decimal(87.5))) + self.assertEqual( + self.bob.get_bom_price_range(1, internal=True), + (Decimal(27.5), Decimal(87.5)) + ) From 7ecd4c70e7823485e88324868182ba9446649997 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 11:02:57 +1100 Subject: [PATCH 26/48] JS linting --- InvenTree/templates/js/translated/build.js | 29 ++++++++-------------- InvenTree/templates/js/translated/forms.js | 6 ++--- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 4e385d9f9a..90690cf728 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -4,7 +4,6 @@ /* globals buildStatusDisplay, constructForm, - getFieldByName, global_settings, imageHoverIcon, inventreeGet, @@ -397,7 +396,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var row = $(table).bootstrapTable('getRowByUniqueId', pk); if (!row) { - console.log("WARNING: getRowByUniqueId returned null"); + console.log('WARNING: getRowByUniqueId returned null'); return; } @@ -834,12 +833,6 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { // ID of the associated "build output" (or null) var output_id = options.output || null; - var query_params = { - part: part_id, - sub_part_detail: true, - sub_part_trackable: output_id != null - }; - var source_location = options.source_location; function renderBomItemRow(bom_item, quantity) { @@ -915,7 +908,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { return html; } - var table_entries = ""; + var table_entries = ''; for (var idx = 0; idx < bom_items.length; idx++) { var bom_item = bom_items[idx]; @@ -1045,7 +1038,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { // Add callback to "clear" button for take_from field addClearCallback( - "take_from", + 'take_from', take_from_field, options, ); @@ -1098,7 +1091,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { // Provide nested values opts.nested = { - "items": item_pk_values + 'items': item_pk_values }; inventreePut( @@ -1116,13 +1109,13 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { }, error: function(xhr) { switch (xhr.status) { - case 400: - handleFormErrors(xhr.responseJSON, fields, opts); - break; - default: - $(opts.modal).modal('hide'); - showApiError(xhr); - break; + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr); + break; } } } diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 2d8a563550..bffb42403b 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -905,7 +905,7 @@ function handleNestedErrors(errors, field_name, options) { return; } - var nest_list = nest_list = options["nested"][field_name]; + var nest_list = nest_list = options['nested'][field_name]; // Nest list must be provided! if (!nest_list) { @@ -996,7 +996,7 @@ function handleFormErrors(errors, fields, options) { var field = fields[field_name]; - if ((field.type == "field") && ("child" in field)) { + if ((field.type == 'field') && ('child' in field)) { // This is a "nested" field handleNestedErrors(errors, field_name, options); } else { @@ -1348,7 +1348,7 @@ function initializeRelatedField(field, fields, options) { query.limit = pageSize; // Allow custom run-time filter augmentation - if ("adjustFilters" in field) { + if ('adjustFilters' in field) { query = field.adjustFilters(query); } From 29588ff2c5baf70562eb883da846008f20341aec Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 11:20:43 +1100 Subject: [PATCH 27/48] Refactor the POReceive API endpoint - Lessons learned from the build allocate - Use serializer.save() directly --- InvenTree/build/tests.py | 2 +- InvenTree/order/api.py | 70 ---------------------------------- InvenTree/order/serializers.py | 42 +++++++++++++++++++- 3 files changed, 42 insertions(+), 72 deletions(-) diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index dc44c3c6c8..93c6bfd511 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -172,7 +172,7 @@ class TestBuildAPI(APITestCase): # Filter by 'part' status response = self.client.get(url, {'part': 25}, format='json') - self.assertEqual(len(response.data), 2) + self.assertEqual(len(response.data), 1) # Filter by an invalid part response = self.client.get(url, {'part': 99999}, format='json') diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index ab6c4d7c0b..fc19610320 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -252,76 +252,6 @@ class POReceive(generics.CreateAPIView): return order - def create(self, request, *args, **kwargs): - - # Which purchase order are we receiving against? - self.order = self.get_order() - - # Validate the serialized data - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - # Receive the line items - try: - self.receive_items(serializer) - except DjangoValidationError as exc: - # Re-throw a django error as a DRF error - raise ValidationError(detail=serializers.as_serializer_error(exc)) - - headers = self.get_success_headers(serializer.data) - - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - - @transaction.atomic - def receive_items(self, serializer): - """ - Receive the items - - At this point, much of the heavy lifting has been done for us by DRF serializers! - - We have a list of "items", each a dict which contains: - - line_item: A PurchaseOrderLineItem matching this order - - location: A destination location - - quantity: A validated numerical quantity - - status: The status code for the received item - """ - - data = serializer.validated_data - - location = data['location'] - - items = data['items'] - - # Check if the location is not specified for any particular item - for item in items: - - line = item['line_item'] - - if not item.get('location', None): - # If a global location is specified, use that - item['location'] = location - - if not item['location']: - # The line item specifies a location? - item['location'] = line.get_destination() - - if not item['location']: - raise ValidationError({ - 'location': _("Destination location must be specified"), - }) - - # Now we can actually receive the items - for item in items: - - self.order.receive_line_item( - item['line_item'], - item['location'], - item['quantity'], - self.request.user, - status=item['status'], - barcode=item.get('barcode', ''), - ) - class POLineItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of POLineItem objects diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 0a93510c6f..9600e2acaf 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -7,7 +7,7 @@ from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ -from django.db import models +from django.db import models, transaction from django.db.models import Case, When, Value from django.db.models import BooleanField, ExpressionWrapper, F @@ -301,6 +301,46 @@ class POReceiveSerializer(serializers.Serializer): return data + def save(self): + + data = self.validated_data + + order = self.context['order'] + + items = data['items'] + location = data.get('location', None) + + # Check if the location is not specified for any particular item + for item in items: + + line = item['line_item'] + + if not item.get('location', None): + # If a global location is specified, use that + item['location'] = location + + if not item['location']: + # The line item specifies a location? + item['location'] = line.get_destination() + + if not item['location']: + raise ValidationError({ + 'location': _("Destination location must be specified"), + }) + + # Now we can actually receive the items into stock + with transaction.atomic(): + for item in items: + order.receive_line_item( + item['line_item'], + item['location'], + item['quantity'], + self.request.user, + status=item['status'], + barcode=item.get('barcode', ''), + ) + + class Meta: fields = [ 'items', From ddcfc8996cb46317f1805de730f7a0b4487d1a71 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 12:12:37 +1100 Subject: [PATCH 28/48] Further fixes for API / serializer classes - Correctly catch and re-throw errors - Provide request to serializer context --- InvenTree/build/api.py | 1 + InvenTree/build/serializers.py | 22 +++++++++++++--------- InvenTree/build/test_api.py | 2 +- InvenTree/order/api.py | 4 +--- InvenTree/order/serializers.py | 22 ++++++++++++++-------- 5 files changed, 30 insertions(+), 21 deletions(-) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 3973272944..cc897d6ec9 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -222,6 +222,7 @@ class BuildAllocate(generics.CreateAPIView): context = super().get_serializer_context() context['build'] = self.get_build() + context['request'] = self.request return context diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 7dc8b7b9d0..53e71dbd27 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -6,7 +6,7 @@ JSON serializers for Build API from __future__ import unicode_literals from django.db import transaction - +from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.translation import ugettext_lazy as _ from django.db.models import Case, When, Value @@ -270,14 +270,18 @@ class BuildAllocationSerializer(serializers.Serializer): quantity = item['quantity'] output = item.get('output', None) - # Create a new BuildItem to allocate stock - BuildItem.objects.create( - build=build, - bom_item=bom_item, - stock_item=stock_item, - quantity=quantity, - install_into=output - ) + try: + # Create a new BuildItem to allocate stock + BuildItem.objects.create( + build=build, + bom_item=bom_item, + stock_item=stock_item, + quantity=quantity, + install_into=output + ) + except (ValidationError, DjangoValidationError) as exc: + # Catch model errors and re-throw as DRF errors + raise ValidationError(detail=serializers.as_serializer_error(exc)) class BuildItemSerializer(InvenTreeModelSerializer): diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index 4b5b04fca9..017f0126c5 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -111,7 +111,7 @@ class BuildAllocationTest(BuildAPITest): expected_code=400 ).data - self.assertIn('Allocation items must be provided', str(data['items'])) + self.assertIn('Allocation items must be provided', str(data)) # No new BuildItem objects have been created during this test self.assertEqual(self.n, BuildItem.objects.count()) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index fc19610320..b8f54ba72b 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -7,14 +7,11 @@ from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ from django.conf.urls import url, include -from django.db import transaction -from django.core.exceptions import ValidationError as DjangoValidationError from django_filters import rest_framework as rest_filters from rest_framework import generics from rest_framework import filters, status from rest_framework.response import Response -from rest_framework import serializers from rest_framework.serializers import ValidationError @@ -235,6 +232,7 @@ class POReceive(generics.CreateAPIView): # Pass the purchase order through to the serializer for validation context['order'] = self.get_order() + context['request'] = self.request return context diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 9600e2acaf..742f759f82 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -7,6 +7,7 @@ from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models, transaction from django.db.models import Case, When, Value from django.db.models import BooleanField, ExpressionWrapper, F @@ -305,6 +306,7 @@ class POReceiveSerializer(serializers.Serializer): data = self.validated_data + request = self.context['request'] order = self.context['order'] items = data['items'] @@ -331,15 +333,19 @@ class POReceiveSerializer(serializers.Serializer): # Now we can actually receive the items into stock with transaction.atomic(): for item in items: - order.receive_line_item( - item['line_item'], - item['location'], - item['quantity'], - self.request.user, - status=item['status'], - barcode=item.get('barcode', ''), - ) + try: + order.receive_line_item( + item['line_item'], + item['location'], + item['quantity'], + request.user, + status=item['status'], + barcode=item.get('barcode', ''), + ) + except (ValidationError, DjangoValidationError) as exc: + # Catch model errors and re-throw as DRF errors + raise ValidationError(detail=serializers.as_serializer_error(exc)) class Meta: fields = [ From 99503a60d3f97212dd3247c4ea637ea6758aa5f1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 12:16:41 +1100 Subject: [PATCH 29/48] Fix for "barcode" field in purchase order receive serializer - Allow blank values! --- InvenTree/order/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index da2d23cd0d..f288f8a8e2 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -235,6 +235,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): help_text=_('Unique identifier field'), default='', required=False, + allow_blank=True, ) def validate_barcode(self, barcode): From a3ba33cae884b06e1af0d60b88ea3f725734e96b Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 12:32:54 +1100 Subject: [PATCH 30/48] Fixes for build output tables - Only show "completed" builds in the "completed builds" table (should be obvious) - Display "serial number" appropriately in build output allocation table --- InvenTree/build/templates/build/detail.html | 1 + InvenTree/stock/serializers.py | 1 + InvenTree/templates/js/translated/build.js | 11 +++++++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 421cac059c..cf3a4bece1 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -292,6 +292,7 @@ loadStockTable($("#build-stock-table"), { location_detail: true, part_detail: true, build: {{ build.id }}, + is_building: false, }, groupByField: 'location', buttons: [ diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 535321ca80..70dd55a4eb 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -64,6 +64,7 @@ class StockItemSerializerBrief(InvenTreeModelSerializer): 'location', 'location_name', 'quantity', + 'serial', ] diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index d359d6cf4e..dbcdbecb5e 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -623,8 +623,15 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var url = ''; - if (row.serial && row.quantity == 1) { - text = `{% trans "Serial Number" %}: ${row.serial}`; + + var serial = row.serial; + + if (row.stock_item_detail) { + serial = row.stock_item_detail.serial; + } + + if (serial && row.quantity == 1) { + text = `{% trans "Serial Number" %}: ${serial}`; } else { text = `{% trans "Quantity" %}: ${row.quantity}`; } From b75e817447c2048b5cc7d07e5470232e1e9c3876 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 14:57:50 +1100 Subject: [PATCH 31/48] Construct bare-bones API form --- .../order/templates/order/order_base.html | 23 ++- InvenTree/templates/js/translated/build.js | 1 - InvenTree/templates/js/translated/order.js | 142 +++++++++++++++++- 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 0d46207c33..6f01d0a172 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -49,7 +49,7 @@ src="{% static 'img/blank_image.png' %}" {% elif order.status == PurchaseOrderStatus.PLACED %}
`; - constructForm(`/api/build/${build_id}/allocate/`, { method: 'POST', fields: {}, diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 532ab81655..613499344c 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -12,6 +12,7 @@ loadTableFilters, makeIconBadge, purchaseOrderStatusDisplay, + receivePurchaseOrderItems, renderLink, salesOrderStatusDisplay, setupFilterList, @@ -234,6 +235,145 @@ function newPurchaseOrderFromOrderWizard(e) { }); } + +/** + * Receive stock items against a PurchaseOrder + * Uses the POReceive API endpoint + * + * arguments: + * - order_id, ID / PK for the PurchaseOrder instance + * - line_items: A list of PurchaseOrderLineItems objects to be allocated + * + * options: + * - + */ +function receivePurchaseOrderItems(order_id, line_items, options={}) { + + function renderLineItem(line_item, opts={}) { + + var pk = line_item.pk; + + // Part thumbnail + description + var thumb = thumbnailImage(line_item.part_detail.thumbnail); + + // Quantity to Receive + var quantity_input = constructField( + `items_quantity_${pk}`, + { + type: 'decimal', + min_value: 0, + value: opts.quantity || 0, + title: '{% trans "Quantity to receive" %}', + required: true, + }, + { + hideLabels: true, + } + ); + + var destination_input = constructField( + `items_location_${pk}`, + { + type: 'related field', + label: '{% trans "Location" %}', + required: false, + }, + { + hideLabels: true, + } + ); + + // Button to remove the row + var delete_button = `
`; + + delete_button += makeIconButton( + 'fa-times icon-red', + 'button-row-remove', + pk, + '{% trans "Remove row" %}', + ); + + delete_button += '
'; + + var html = ` + + + ${thumb} ${line_item.part_detail.full_name} + + + ${line_item.supplier_part_detail.SKU} + + + ${line_item.quantity} + + + ${line_item.received} + + + ${quantity_input} + + + STATUS + + + ${destination_input} + + + ${delete_button} + + `; + + return html; + } + + var table_entries = ''; + + line_items.forEach(function(item) { + table_entries += renderLineItem(item); + }); + + var html = ``; + + // Add table + html += ` + + + + + + + + + + + + + + + ${table_entries} + +
{% trans "Part" %}{% trans "Order Code" %}{% trans "On Order" %}{% trans "Received" %}{% trans "Receive" %}{% trans "Status" %}{% trans "Destination" %}
+ `; + + constructForm(`/api/order/po/${order_id}/receive/`, { + method: 'POST', + fields: { + location: {}, + }, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm receipt of items" %}', + title: '{% trans "Receive Purchase Order Items" %}', + afterRender: function(fields, opts) { + // TODO + }, + onSubmit: function(fields, opts) { + // TODO + } + }); +} + + function editPurchaseOrderLineItem(e) { /* Edit a purchase order line item in a modal form. @@ -618,7 +758,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) { } if (options.allow_receive && row.received < row.quantity) { - html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}'); + html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}'); } html += `
`; From a579bc8721dc8b57d9c05a5f3be8e5f4f1c169b3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 15:10:16 +1100 Subject: [PATCH 32/48] Initialize destination field --- .../js/translated/model_renderers.js | 8 +++- InvenTree/templates/js/translated/order.js | 46 +++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 3136ebee29..0c3dabc27e 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -112,7 +112,13 @@ function renderStockLocation(name, data, parameters, options) { var html = `${level}${data.pathstring}`; - if (data.description) { + var render_description = true; + + if ('render_description' in parameters) { + render_description = parameters['render_description']; + } + + if (render_description && data.description) { html += ` - ${data.description}`; } diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 613499344c..ff41ca610a 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -249,6 +249,14 @@ function newPurchaseOrderFromOrderWizard(e) { */ function receivePurchaseOrderItems(order_id, line_items, options={}) { + if (line_items.length == 0) { + showAlertDialog( + '{% trans "Select Line Items" %}', + '{% trans "At least one line item must be selected" %}', + ); + return; + } + function renderLineItem(line_item, opts={}) { var pk = line_item.pk; @@ -343,9 +351,9 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { {% trans "Order Code" %} {% trans "On Order" %} {% trans "Received" %} - {% trans "Receive" %} + {% trans "Receive" %} {% trans "Status" %} - {% trans "Destination" %} + {% trans "Destination" %} @@ -365,7 +373,39 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { confirmMessage: '{% trans "Confirm receipt of items" %}', title: '{% trans "Receive Purchase Order Items" %}', afterRender: function(fields, opts) { - // TODO + // Initialize the "destination" field for each item + line_items.forEach(function(item) { + + var pk = item.pk; + + var name = `items_location_${pk}`; + + var field_details = { + name: name, + api_url: '{% url "api-location-list" %}', + filters: { + + }, + type: 'related field', + model: 'stocklocation', + required: false, + auto_fill: false, + value: item.destination, + render_description: false, + }; + + initializeRelatedField( + field_details, + null, + opts, + ); + + addClearCallback( + name, + field_details, + opts + ); + }); }, onSubmit: function(fields, opts) { // TODO From dd4428464ddb4b3be9eaf3f12956e45a38afbe75 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 16:05:18 +1100 Subject: [PATCH 33/48] Receiving items against a purchase order now makes use of the API forms - Delete old unused code - Improve serializer validation --- InvenTree/order/forms.py | 16 -- InvenTree/order/serializers.py | 51 +++-- .../order/templates/order/order_base.html | 17 +- .../order/templates/order/receive_parts.html | 81 -------- InvenTree/order/urls.py | 1 - InvenTree/order/views.py | 196 ------------------ InvenTree/part/serializers.py | 1 + InvenTree/templates/js/translated/forms.js | 1 + InvenTree/templates/js/translated/order.js | 160 +++++++++++--- 9 files changed, 168 insertions(+), 356 deletions(-) delete mode 100644 InvenTree/order/templates/order/receive_parts.html diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index e0c500e5e3..15b9e740d8 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -80,22 +80,6 @@ class ShipSalesOrderForm(HelperForm): ] -class ReceivePurchaseOrderForm(HelperForm): - - location = TreeNodeChoiceField( - queryset=StockLocation.objects.all(), - required=False, - label=_("Destination"), - help_text=_("Set all received parts listed above to this location (if left blank, use \"Destination\" column value in above table)"), - ) - - class Meta: - model = PurchaseOrder - fields = [ - "location", - ] - - class AllocateSerialsToSalesOrderForm(forms.Form): """ Form for assigning stock to a sales order, diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 842d422678..04ded4fbbb 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -225,6 +225,13 @@ class POLineItemReceiveSerializer(serializers.Serializer): required=True, ) + def validate_quantity(self, quantity): + + if quantity <= 0: + raise ValidationError(_("Quantity must be greater than zero")) + + return quantity + status = serializers.ChoiceField( choices=list(StockStatus.items()), default=StockStatus.OK, @@ -246,7 +253,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): # Ignore empty barcode values if not barcode or barcode.strip() == '': - return + return None if stock.models.StockItem.objects.filter(uid=barcode).exists(): raise ValidationError(_('Barcode is already in use')) @@ -284,10 +291,28 @@ class POReceiveSerializer(serializers.Serializer): items = data.get('items', []) + location = data.get('location', None) + if len(items) == 0: - raise ValidationError({ - 'items': _('Line items must be provided') - }) + raise ValidationError(_('Line items must be provided')) + + # Check if the location is not specified for any particular item + for item in items: + + line = item['line_item'] + + if not item.get('location', None): + # If a global location is specified, use that + item['location'] = location + + if not item['location']: + # The line item specifies a location? + item['location'] = line.get_destination() + + if not item['location']: + raise ValidationError({ + 'location': _("Destination location must be specified"), + }) # Ensure barcodes are unique unique_barcodes = set() @@ -313,24 +338,6 @@ class POReceiveSerializer(serializers.Serializer): items = data['items'] location = data.get('location', None) - # Check if the location is not specified for any particular item - for item in items: - - line = item['line_item'] - - if not item.get('location', None): - # If a global location is specified, use that - item['location'] = location - - if not item['location']: - # The line item specifies a location? - item['location'] = line.get_destination() - - if not item['location']: - raise ValidationError({ - 'location': _("Destination location must be specified"), - }) - # Now we can actually receive the items into stock with transaction.atomic(): for item in items: diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 6f01d0a172..69e972da6c 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -204,22 +204,11 @@ $("#receive-order").click(function() { {{ order.id }}, items_to_receive, { + success: function() { + $("#po-line-table").bootstrapTable('refresh'); + } } ); - - return; - - launchModalForm("{% url 'po-receive' order.id %}", { - reload: true, - secondary: [ - { - field: 'location', - label: '{% trans "New Location" %}', - title: '{% trans "Create new stock location" %}', - url: "{% url 'stock-location-create' %}", - }, - ] - }); }); $("#complete-order").click(function() { diff --git a/InvenTree/order/templates/order/receive_parts.html b/InvenTree/order/templates/order/receive_parts.html deleted file mode 100644 index 7b12101f7f..0000000000 --- a/InvenTree/order/templates/order/receive_parts.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} -{% load inventree_extras %} -{% load status_codes %} - -{% block form %} - -{% blocktrans with desc=order.description %}Receive outstanding parts for {{order}} - {{desc}}{% endblocktrans %} - -
- {% csrf_token %} - {% load crispy_forms_tags %} - - -

{% trans "Fill out number of parts received, the status and destination" %}

- - - - - - - - - - - - - {% for line in lines %} - - {% if line.part %} - - - {% else %} - - {% endif %} - - - - - - - - {% endfor %} -
{% trans "Part" %}{% trans "Order Code" %}{% trans "On Order" %}{% trans "Received" %}{% trans "Receive" %}{% trans "Status" %}{% trans "Destination" %}
- {% include "hover_image.html" with image=line.part.part.image hover=False %} - {{ line.part.part.full_name }} - {{ line.part.SKU }}{% trans "Error: Referenced part has been removed" %}{% decimal line.quantity %}{% decimal line.received %} -
-
- -
-
-
-
- -
-
-
- -
-
- -
- - {% crispy form %} - -
{{ form_errors }}
-
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 2ce90f1f81..5ea9a56867 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -13,7 +13,6 @@ purchase_order_detail_urls = [ url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'), url(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'), - url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'), url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'), url(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'), diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index e8b0dc03e9..a953324b47 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -468,202 +468,6 @@ class PurchaseOrderExport(AjaxView): return DownloadFile(filedata, filename) -class PurchaseOrderReceive(AjaxUpdateView): - """ View for receiving parts which are outstanding against a PurchaseOrder. - - Any parts which are outstanding are listed. - If all parts are marked as received, the order is closed out. - - """ - - form_class = order_forms.ReceivePurchaseOrderForm - ajax_form_title = _("Receive Parts") - ajax_template_name = "order/receive_parts.html" - - # Specify role as we do not specify a Model against this view - role_required = 'purchase_order.change' - - # Where the parts will be going (selected in POST request) - destination = None - - def get_context_data(self): - - ctx = { - 'order': self.order, - 'lines': self.lines, - 'stock_locations': StockLocation.objects.all(), - } - - return ctx - - def get_lines(self): - """ - Extract particular line items from the request, - or default to *all* pending line items if none are provided - """ - - lines = None - - if 'line' in self.request.GET: - line_id = self.request.GET.get('line') - - try: - lines = PurchaseOrderLineItem.objects.filter(pk=line_id) - except (PurchaseOrderLineItem.DoesNotExist, ValueError): - pass - - # TODO - Option to pass multiple lines? - - # No lines specified - default selection - if lines is None: - lines = self.order.pending_line_items() - - return lines - - def get(self, request, *args, **kwargs): - """ Respond to a GET request. Determines which parts are outstanding, - and presents a list of these parts to the user. - """ - - self.request = request - self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) - - self.lines = self.get_lines() - - for line in self.lines: - # Pre-fill the remaining quantity - line.receive_quantity = line.remaining() - - return self.renderJsonResponse(request, form=self.get_form()) - - def post(self, request, *args, **kwargs): - """ Respond to a POST request. Data checking and error handling. - If the request is valid, new StockItem objects will be made - for each received item. - """ - - self.request = request - self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) - errors = False - - self.lines = [] - self.destination = None - - msg = _("Items received") - - # Extract the destination for received parts - if 'location' in request.POST: - pk = request.POST['location'] - try: - self.destination = StockLocation.objects.get(id=pk) - except (StockLocation.DoesNotExist, ValueError): - pass - - # Extract information on all submitted line items - for item in request.POST: - if item.startswith('line-'): - pk = item.replace('line-', '') - - try: - line = PurchaseOrderLineItem.objects.get(id=pk) - except (PurchaseOrderLineItem.DoesNotExist, ValueError): - continue - - # Check that the StockStatus was set - status_key = 'status-{pk}'.format(pk=pk) - status = request.POST.get(status_key, StockStatus.OK) - - try: - status = int(status) - except ValueError: - status = StockStatus.OK - - if status in StockStatus.RECEIVING_CODES: - line.status_code = status - else: - line.status_code = StockStatus.OK - - # Check the destination field - line.destination = None - if self.destination: - # If global destination is set, overwrite line value - line.destination = self.destination - else: - destination_key = f'destination-{pk}' - destination = request.POST.get(destination_key, None) - - if destination: - try: - line.destination = StockLocation.objects.get(pk=destination) - except (StockLocation.DoesNotExist, ValueError): - pass - - # Check that line matches the order - if not line.order == self.order: - # TODO - Display a non-field error? - continue - - # Ignore a part that doesn't map to a SupplierPart - try: - if line.part is None: - continue - except SupplierPart.DoesNotExist: - continue - - receive = self.request.POST[item] - - try: - receive = Decimal(receive) - except InvalidOperation: - # In the case on an invalid input, reset to default - receive = line.remaining() - msg = _("Error converting quantity to number") - errors = True - - if receive < 0: - receive = 0 - errors = True - msg = _("Receive quantity less than zero") - - line.receive_quantity = receive - self.lines.append(line) - - if len(self.lines) == 0: - msg = _("No lines specified") - errors = True - - # No errors? Receive the submitted parts! - if errors is False: - self.receive_parts() - - data = { - 'form_valid': errors is False, - 'success': msg, - } - - return self.renderJsonResponse(request, data=data, form=self.get_form()) - - @transaction.atomic - def receive_parts(self): - """ Called once the form has been validated. - Create new stockitems against received parts. - """ - - for line in self.lines: - - if not line.part: - continue - - self.order.receive_line_item( - line, - line.destination, - line.receive_quantity, - self.request.user, - status=line.status_code, - purchase_price=line.purchase_price, - ) - - class OrderParts(AjaxView): """ View for adding various SupplierPart items to a Purchase Order. diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 060faf8b0d..4f1ba8cc8b 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -193,6 +193,7 @@ class PartBriefSerializer(InvenTreeModelSerializer): fields = [ 'pk', 'IPN', + 'default_location', 'name', 'revision', 'full_name', diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index bffb42403b..b43ce0cb2d 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1515,6 +1515,7 @@ function initializeChoiceField(field, fields, options) { select.select2({ dropdownAutoWidth: false, dropdownParent: $(options.modal), + width: '100%', }); } diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index ff41ca610a..8a41082cb8 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -264,13 +264,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { // Part thumbnail + description var thumb = thumbnailImage(line_item.part_detail.thumbnail); + var quantity = (line_item.quantity || 0) - (line_item.received || 0); + + if (quantity < 0) { + quantity = 0; + } + // Quantity to Receive var quantity_input = constructField( `items_quantity_${pk}`, { type: 'decimal', min_value: 0, - value: opts.quantity || 0, + value: quantity, title: '{% trans "Quantity to receive" %}', required: true, }, @@ -279,6 +285,16 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { } ); + // Construct list of StockItem status codes + var choices = []; + + for (var key in stockCodes) { + choices.push({ + value: key, + display_name: stockCodes[key].value, + }); + } + var destination_input = constructField( `items_location_${pk}`, { @@ -291,6 +307,20 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { } ); + var status_input = constructField( + `items_status_${pk}`, + { + type: 'choice', + label: '{% trans "Stock Status" %}', + required: true, + choices: choices, + value: 10, // OK + }, + { + hideLabels: true, + } + ); + // Button to remove the row var delete_button = `
`; @@ -321,7 +351,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { ${quantity_input} - STATUS + ${status_input} ${destination_input} @@ -349,11 +379,11 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { {% trans "Part" %} {% trans "Order Code" %} - {% trans "On Order" %} + {% trans "Ordered" %} {% trans "Received" %} {% trans "Receive" %} - {% trans "Status" %} - {% trans "Destination" %} + {% trans "Status" %} + {% trans "Destination" %} @@ -390,7 +420,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { model: 'stocklocation', required: false, auto_fill: false, - value: item.destination, + value: item.destination || item.part_detail.default_location, render_description: false, }; @@ -405,10 +435,86 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { field_details, opts ); + + initializeChoiceField( + { + name: `items_status_${pk}`, + }, + null, + opts + ); + }); + + // Add callbacks to remove rows + $(opts.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(opts.modal).find(`#receive_row_${pk}`).remove(); }); }, onSubmit: function(fields, opts) { - // TODO + // Extract data elements from the form + var data = { + items: [], + location: getFormFieldValue('location', {}, opts), + }; + + var item_pk_values = []; + + line_items.forEach(function(item) { + + var pk = item.pk; + + var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts); + + var status = getFormFieldValue(`items_status_${pk}`, {}, opts); + + var location = getFormFieldValue(`items_location_${pk}`, {}, opts); + + if (quantity != null) { + data.items.push({ + line_item: pk, + quantity: quantity, + status: status, + location: location, + }); + + item_pk_values.push(pk); + } + + }); + + // Provide list of nested values + opts.nested = { + 'items': item_pk_values, + }; + + inventreePut( + opts.url, + data, + { + method: 'POST', + success: function(response) { + // Hide the modal + $(opts.modal).modal('hide'); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr); + break; + } + } + } + ) } }); } @@ -604,22 +710,24 @@ function loadPurchaseOrderLineItemTable(table, options={}) { $(table).find('.button-line-receive').click(function() { var pk = $(this).attr('pk'); - launchModalForm(`/order/purchase-order/${options.order}/receive/`, { - success: function() { - $(table).bootstrapTable('refresh'); - }, - data: { - line: pk, - }, - secondary: [ - { - field: 'location', - label: '{% trans "New Location" %}', - title: '{% trans "Create new stock location" %}', - url: '{% url "stock-location-create" %}', - }, - ] - }); + var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); + + if (!line_item) { + console.log('WARNING: getRowByUniqueId returned null'); + return; + } + + receivePurchaseOrderItems( + options.order, + [ + line_item, + ], + { + success: function() { + $(table).bootstrapTable('refresh'); + } + } + ); }); } } @@ -637,11 +745,11 @@ function loadPurchaseOrderLineItemTable(table, options={}) { }, url: '{% url "api-po-line-list" %}', showFooter: true, + uniqueId: 'pk', columns: [ { - field: 'pk', - title: 'ID', - visible: false, + checkbox: true, + visible: true, switchable: false, }, { From 1ea9b6f4cf78cba2e315f0bac3953047adcc6b53 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 16:10:00 +1100 Subject: [PATCH 34/48] PEP fixes --- InvenTree/order/forms.py | 3 --- InvenTree/order/serializers.py | 8 +++++++- InvenTree/order/views.py | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 15b9e740d8..87e042f4f3 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -8,8 +8,6 @@ from __future__ import unicode_literals from django import forms from django.utils.translation import ugettext_lazy as _ -from mptt.fields import TreeNodeChoiceField - from InvenTree.forms import HelperForm from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField @@ -19,7 +17,6 @@ from common.forms import MatchItemForm import part.models -from stock.models import StockLocation from .models import PurchaseOrder from .models import SalesOrder, SalesOrderLineItem from .models import SalesOrderAllocation diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 04ded4fbbb..3886bfd3a5 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -329,6 +329,9 @@ class POReceiveSerializer(serializers.Serializer): return data def save(self): + """ + Perform the actual database transaction to receive purchase order items + """ data = self.validated_data @@ -342,10 +345,13 @@ class POReceiveSerializer(serializers.Serializer): with transaction.atomic(): for item in items: + # Select location + loc = item.get('location', None) or item['line_item'].get_destination() or location + try: order.receive_line_item( item['line_item'], - item['location'], + loc, item['quantity'], request.user, status=item['status'], diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index a953324b47..8a5e709926 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -26,7 +26,7 @@ from .models import SalesOrderAllocation from .admin import POLineItemResource from build.models import Build from company.models import Company, SupplierPart # ManufacturerPart -from stock.models import StockItem, StockLocation +from stock.models import StockItem from part.models import Part from common.models import InvenTreeSetting @@ -42,7 +42,7 @@ from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers import extract_serial_numbers from InvenTree.views import InvenTreeRoleMixin -from InvenTree.status_codes import PurchaseOrderStatus, StockStatus +from InvenTree.status_codes import PurchaseOrderStatus logger = logging.getLogger("inventree") From ce7552bc5b500fb18503e6084d449c83221ce3e3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 16:10:46 +1100 Subject: [PATCH 35/48] JS linting --- InvenTree/templates/js/translated/order.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 8a41082cb8..af6582957d 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -514,7 +514,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { } } } - ) + ); } }); } From 05d8f2b216959e3cff7a5e0c78577c594e36fc36 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 16:35:11 +1100 Subject: [PATCH 36/48] Remove outdated unit test case --- InvenTree/order/test_views.py | 83 ----------------------------------- 1 file changed, 83 deletions(-) diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py index 4b49b6c94e..768d7e7942 100644 --- a/InvenTree/order/test_views.py +++ b/InvenTree/order/test_views.py @@ -103,86 +103,3 @@ class POTests(OrderViewTestCase): # Test that the order was actually placed order = PurchaseOrder.objects.get(pk=1) self.assertEqual(order.status, PurchaseOrderStatus.PLACED) - - -class TestPOReceive(OrderViewTestCase): - """ Tests for receiving a purchase order """ - - def setUp(self): - super().setUp() - - self.po = PurchaseOrder.objects.get(pk=1) - self.po.status = PurchaseOrderStatus.PLACED - self.po.save() - self.url = reverse('po-receive', args=(1,)) - - def post(self, data, validate=None): - - response = self.client.post(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - if validate is not None: - - data = json.loads(response.content) - - if validate: - self.assertTrue(data['form_valid']) - else: - self.assertFalse(data['form_valid']) - - return response - - def test_get_dialog(self): - - data = { - } - - self.client.get(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - def test_receive_lines(self): - - post_data = { - } - - self.post(post_data, validate=False) - - # Try with an invalid location - post_data['location'] = 12345 - - self.post(post_data, validate=False) - - # Try with a valid location - post_data['location'] = 1 - - # Should fail due to invalid quantity - self.post(post_data, validate=False) - - # Try to receive against an invalid line - post_data['line-800'] = 100 - - # Remove an invalid quantity of items - post_data['line-1'] = '7x5q' - - self.post(post_data, validate=False) - - # Receive negative number - post_data['line-1'] = -100 - - self.post(post_data, validate=False) - - # Receive 75 items - post_data['line-1'] = 75 - - self.post(post_data, validate=True) - - line = PurchaseOrderLineItem.objects.get(pk=1) - - self.assertEqual(line.received, 75) - - # Receive 30 more items - post_data['line-1'] = 30 - - self.post(post_data, validate=True) - - line = PurchaseOrderLineItem.objects.get(pk=1) - - self.assertEqual(line.received, 105) From 8684b55582785a67b91f6aff98bddd06fbb63664 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 16:39:18 +1100 Subject: [PATCH 37/48] PEP fixes --- InvenTree/order/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py index 768d7e7942..220c1688db 100644 --- a/InvenTree/order/test_views.py +++ b/InvenTree/order/test_views.py @@ -10,7 +10,7 @@ from django.contrib.auth.models import Group from InvenTree.status_codes import PurchaseOrderStatus -from .models import PurchaseOrder, PurchaseOrderLineItem +from .models import PurchaseOrder import json From 87f1bd87e7a6da30d98d53fb58f593770e2c54d2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 16:52:24 +1100 Subject: [PATCH 38/48] Add button to receive selected items --- .../order/purchase_order_detail.html | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 4262a810e0..2bc2c29617 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -18,13 +18,19 @@
- {% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %} + {% if roles.purchase_order.change %} + {% if order.status == PurchaseOrderStatus.PENDING %} {% trans "Upload File" %} + {% elif order.status == PurchaseOrderStatus.PLACED %} + + {% endif %} {% endif %}
@@ -207,6 +213,22 @@ $('#new-po-line').click(function() { }); }); +{% elif order.status == PurchaseOrderStatus.PLACED %} + + $('#receive-selected-items').click(function() { + var items = $("#po-line-table").bootstrapTable('getSelections'); + + receivePurchaseOrderItems( + {{ order.id }}, + items, + { + success: function() { + $("#po-line-table").bootstrapTable('refresh'); + } + } + ); + }); + {% endif %} loadPurchaseOrderLineItemTable('#po-line-table', { From 5e951ef64bd2bc5e4ab92e2b6f325b689ed17133 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 16:57:33 +1100 Subject: [PATCH 39/48] Add 'filtering' for purchase order line item table --- .../order/templates/order/purchase_order_detail.html | 3 +++ InvenTree/templates/js/translated/filters.js | 5 +++++ InvenTree/templates/js/translated/order.js | 12 ++++++++++++ 3 files changed, 20 insertions(+) diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 2bc2c29617..987ab79d58 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -32,6 +32,9 @@ {% endif %} {% endif %} +
+ +
diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js index d7e8f45ca5..3e41003696 100644 --- a/InvenTree/templates/js/translated/filters.js +++ b/InvenTree/templates/js/translated/filters.js @@ -273,6 +273,11 @@ function setupFilterList(tableKey, table, target) { var element = $(target); + if (!element) { + console.log(`WARNING: setupFilterList could not find target '${target}'`); + return; + } + // One blank slate, please element.empty(); diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index af6582957d..0bb6696f98 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -665,6 +665,18 @@ function loadPurchaseOrderTable(table, options) { */ function loadPurchaseOrderLineItemTable(table, options={}) { + options.params = options.params || {}; + + var filters = loadTableFilters('purchaseorderlineitem'); + + for (var key in options.params) { + filters[key] = options.params[key]; + } + + var target = options.filter_target || '#filter-list-purchase-order-lines'; + + setupFilterList('purchaseorderlineitem', $(table), target); + function setupCallbacks() { if (options.allow_edit) { $(table).find('.button-line-edit').click(function() { From dd760bfecdf35993a717d2ac7e624de3813a0a7d Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 17:15:41 +1100 Subject: [PATCH 40/48] Add filtering by "completed" status for purchase order line items --- InvenTree/order/api.py | 35 +++++++++++++++++++ InvenTree/templates/js/translated/order.js | 15 ++++---- .../templates/js/translated/table_filters.js | 11 +++++- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index b8f54ba72b..26e6ed3546 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -7,6 +7,7 @@ from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ from django.conf.urls import url, include +from django.db.models import Q, F from django_filters import rest_framework as rest_filters from rest_framework import generics @@ -251,6 +252,39 @@ class POReceive(generics.CreateAPIView): return order +class POLineItemFilter(rest_filters.FilterSet): + """ + Custom filters for the POLineItemList endpoint + """ + + class Meta: + model = PurchaseOrderLineItem + fields = [ + 'order', + 'part' + ] + + completed = rest_filters.BooleanFilter(label='completed', method='filter_completed') + + def filter_completed(self, queryset, name, value): + """ + Filter by lines which are "completed" (or "not" completed) + + A line is completed when received >= quantity + """ + + value = str2bool(value) + + q = Q(received__gte=F('quantity')) + + if value: + queryset = queryset.filter(q) + else: + queryset = queryset.exclude(q) + + return queryset + + class POLineItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of POLineItem objects @@ -260,6 +294,7 @@ class POLineItemList(generics.ListCreateAPIView): queryset = PurchaseOrderLineItem.objects.all() serializer_class = POLineItemSerializer + filterset_class = POLineItemFilter def get_queryset(self, *args, **kwargs): diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 0bb6696f98..43d4b56936 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -566,12 +566,10 @@ function loadPurchaseOrderTable(table, options) { filters[key] = options.params[key]; } - options.url = options.url || '{% url "api-po-list" %}'; - setupFilterList('purchaseorder', $(table)); $(table).inventreeTable({ - url: options.url, + url: '{% url "api-po-list" %}', queryParams: filters, name: 'purchaseorder', groupBy: false, @@ -667,12 +665,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) { options.params = options.params || {}; + options.params['order'] = options.order; + options.params['part_detail'] = true; + var filters = loadTableFilters('purchaseorderlineitem'); for (var key in options.params) { filters[key] = options.params[key]; } - + var target = options.filter_target || '#filter-list-purchase-order-lines'; setupFilterList('purchaseorderlineitem', $(table), target); @@ -751,10 +752,8 @@ function loadPurchaseOrderLineItemTable(table, options={}) { formatNoMatches: function() { return '{% trans "No line items found" %}'; }, - queryParams: { - order: options.order, - part_detail: true - }, + queryParams: filters, + original: options.params, url: '{% url "api-po-line-list" %}', showFooter: true, uniqueId: 'pk', diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index b94bc324c7..4d12f69780 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -274,7 +274,16 @@ function getAvailableTableFilters(tableKey) { }; } - // Filters for the "Order" table + // Filters for PurchaseOrderLineItem table + if (tableKey == 'purchaseorderlineitem') { + return { + completed: { + type: 'bool', + title: '{% trans "Completed" %}', + }, + }; + } + // Filters for the PurchaseOrder table if (tableKey == 'purchaseorder') { return { From 93c53315cc39a3bc393535396e301808335ba1ae Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 17:35:10 +1100 Subject: [PATCH 41/48] Unit test fixes --- InvenTree/order/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 765c58cc3d..1f7905d1e3 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -251,7 +251,7 @@ class PurchaseOrderReceiveTest(OrderTest): expected_code=400 ).data - self.assertIn('Line items must be provided', str(data['items'])) + self.assertIn('Line items must be provided', str(data)) # No new stock items have been created self.assertEqual(self.n, StockItem.objects.count()) From f197d8b1da0ee3f3220fee8a8215d718fc5d3be3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 21:55:05 +1100 Subject: [PATCH 42/48] Adds a DRF serializer for stock adjustments - Currently the "StockCount" action has been transferred --- InvenTree/stock/api.py | 55 +++++++--------- InvenTree/stock/serializers.py | 117 +++++++++++++++++++++++++++++---- 2 files changed, 128 insertions(+), 44 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 27c2426d53..62f70ff8fc 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -41,11 +41,7 @@ from order.serializers import POSerializer import common.settings import common.models -from .serializers import StockItemSerializer -from .serializers import LocationSerializer, LocationBriefSerializer -from .serializers import StockTrackingSerializer -from .serializers import StockItemAttachmentSerializer -from .serializers import StockItemTestResultSerializer +import stock.serializers as StockSerializers from InvenTree.views import TreeSerializer from InvenTree.helpers import str2bool, isNull @@ -83,12 +79,12 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): """ queryset = StockItem.objects.all() - serializer_class = StockItemSerializer + serializer_class = StockSerializers.StockItemSerializer def get_queryset(self, *args, **kwargs): queryset = super().get_queryset(*args, **kwargs) - queryset = StockItemSerializer.annotate_queryset(queryset) + queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset) return queryset @@ -138,8 +134,6 @@ class StockAdjust(APIView): queryset = StockItem.objects.none() - allow_missing_quantity = False - def get_items(self, request): """ Return a list of items posted to the endpoint. @@ -206,23 +200,22 @@ class StockAdjust(APIView): self.notes = str(request.data.get('notes', '')) -class StockCount(StockAdjust): +class StockCount(generics.CreateAPIView): """ Endpoint for counting stock (performing a stocktake). """ - def post(self, request, *args, **kwargs): + queryset = StockItem.objects.none() - self.get_items(request) + serializer_class = StockSerializers.StockCountSerializer - n = 0 + def get_serializer_context(self): + + context = super().get_serializer_context() - for item in self.items: + context['request'] = self.request - if item['item'].stocktake(item['quantity'], request.user, notes=self.notes): - n += 1 - - return Response({'success': _('Updated stock for {n} items').format(n=n)}) + return context class StockAdd(StockAdjust): @@ -315,7 +308,7 @@ class StockLocationList(generics.ListCreateAPIView): """ queryset = StockLocation.objects.all() - serializer_class = LocationSerializer + serializer_class = StockSerializers.LocationSerializer def filter_queryset(self, queryset): """ @@ -517,7 +510,7 @@ class StockList(generics.ListCreateAPIView): - POST: Create a new StockItem """ - serializer_class = StockItemSerializer + serializer_class = StockSerializers.StockItemSerializer queryset = StockItem.objects.all() filterset_class = StockFilter @@ -639,7 +632,7 @@ class StockList(generics.ListCreateAPIView): # Serialize each StockLocation object for location in locations: - location_map[location.pk] = LocationBriefSerializer(location).data + location_map[location.pk] = StockSerializers.LocationBriefSerializer(location).data # Now update each StockItem with the related StockLocation data for stock_item in data: @@ -665,7 +658,7 @@ class StockList(generics.ListCreateAPIView): queryset = super().get_queryset(*args, **kwargs) - queryset = StockItemSerializer.annotate_queryset(queryset) + queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset) # Do not expose StockItem objects which are scheduled for deletion queryset = queryset.filter(scheduled_for_deletion=False) @@ -954,7 +947,7 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin): """ queryset = StockItemAttachment.objects.all() - serializer_class = StockItemAttachmentSerializer + serializer_class = StockSerializers.StockItemAttachmentSerializer filter_backends = [ DjangoFilterBackend, @@ -973,7 +966,7 @@ class StockAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix """ queryset = StockItemAttachment.objects.all() - serializer_class = StockItemAttachmentSerializer + serializer_class = StockSerializers.StockItemAttachmentSerializer class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView): @@ -982,7 +975,7 @@ class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView): """ queryset = StockItemTestResult.objects.all() - serializer_class = StockItemTestResultSerializer + serializer_class = StockSerializers.StockItemTestResultSerializer class StockItemTestResultList(generics.ListCreateAPIView): @@ -991,7 +984,7 @@ class StockItemTestResultList(generics.ListCreateAPIView): """ queryset = StockItemTestResult.objects.all() - serializer_class = StockItemTestResultSerializer + serializer_class = StockSerializers.StockItemTestResultSerializer filter_backends = [ DjangoFilterBackend, @@ -1039,7 +1032,7 @@ class StockTrackingDetail(generics.RetrieveAPIView): """ queryset = StockItemTracking.objects.all() - serializer_class = StockTrackingSerializer + serializer_class = StockSerializers.StockTrackingSerializer class StockTrackingList(generics.ListAPIView): @@ -1052,7 +1045,7 @@ class StockTrackingList(generics.ListAPIView): """ queryset = StockItemTracking.objects.all() - serializer_class = StockTrackingSerializer + serializer_class = StockSerializers.StockTrackingSerializer def get_serializer(self, *args, **kwargs): try: @@ -1088,7 +1081,7 @@ class StockTrackingList(generics.ListAPIView): if 'location' in deltas: try: location = StockLocation.objects.get(pk=deltas['location']) - serializer = LocationSerializer(location) + serializer = StockSerializers.LocationSerializer(location) deltas['location_detail'] = serializer.data except: pass @@ -1097,7 +1090,7 @@ class StockTrackingList(generics.ListAPIView): if 'stockitem' in deltas: try: stockitem = StockItem.objects.get(pk=deltas['stockitem']) - serializer = StockItemSerializer(stockitem) + serializer = StockSerializers.StockItemSerializer(stockitem) deltas['stockitem_detail'] = serializer.data except: pass @@ -1179,7 +1172,7 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView): """ queryset = StockLocation.objects.all() - serializer_class = LocationSerializer + serializer_class = StockSerializers.LocationSerializer stock_api_urls = [ diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 70dd55a4eb..801b9a9d94 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -2,27 +2,29 @@ JSON serializers for Stock app """ -from rest_framework import serializers +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from decimal import Decimal +from datetime import datetime, timedelta +from django.db import transaction from django.utils.translation import ugettext_lazy as _ +from django.db.models.functions import Coalesce +from django.db.models import Case, When, Value +from django.db.models import BooleanField +from django.db.models import Q + +from rest_framework import serializers +from rest_framework.serializers import ValidationError + +from sql_util.utils import SubquerySum, SubqueryCount from .models import StockItem, StockLocation from .models import StockItemTracking from .models import StockItemAttachment from .models import StockItemTestResult -from django.db.models.functions import Coalesce - -from django.db.models import Case, When, Value -from django.db.models import BooleanField -from django.db.models import Q - -from sql_util.utils import SubquerySum, SubqueryCount - -from decimal import Decimal - -from datetime import datetime, timedelta - import common.models from common.settings import currency_code_default, currency_code_mappings @@ -396,3 +398,92 @@ class StockTrackingSerializer(InvenTreeModelSerializer): 'label', 'tracking_type', ] + + +class StockAdjustmentItemSerializer(serializers.Serializer): + """ + Serializer for a single StockItem within a stock adjument request. + + Fields: + - item: StockItem object + - quantity: Numerical quantity + """ + + class Meta: + fields = [ + 'item', + 'quantity' + ] + + pk = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('StockItem primary key value') + ) + + quantity = serializers.DecimalField( + max_digits=15, + decimal_places=5, + min_value=0, + required=True + ) + + +class StockAdjustmentSerializer(serializers.Serializer): + """ + Base class for managing stock adjustment actions via the API + """ + + class Meta: + fields = [ + 'items', + 'notes', + ] + + items = StockAdjustmentItemSerializer(many=True) + + notes = serializers.CharField( + required=False, + allow_blank=False, + ) + + def validate(self, data): + + super().validate(data) + + items = data.get('items', []) + + if len(items) == 0: + raise ValidationError(_("A list of stock items must be provided")) + + return data + + +class StockCountSerializer(StockAdjustmentSerializer): + """ + Serializer for counting stock items + """ + + def save(self): + """ + Perform the database transactions to count the stock + """ + request = self.context['request'] + + data = self.validated_data + items = data['items'] + notes = data['notes'] + + with transaction.atomic(): + for item in items: + + stock_item = item['pk'] + quantity = item['quantity'] + + stock_item.stocktake( + quantity, + request.user, + notes=notes + ) From 102f886d81058cd784910bd0d128e1a71d01fdb7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 22:26:21 +1100 Subject: [PATCH 43/48] All stock adjustment actions ported to new scheme - Bumped API version too --- InvenTree/InvenTree/version.py | 6 +- InvenTree/stock/api.py | 154 ++++----------------------------- InvenTree/stock/serializers.py | 113 ++++++++++++++++++++++-- 3 files changed, 128 insertions(+), 145 deletions(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 1d9423371f..03ee877cb2 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,11 +10,15 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" -INVENTREE_API_VERSION = 13 +INVENTREE_API_VERSION = 14 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v14 -> 2021-20-05 + - Stock adjustment actions API is improved, using native DRF serializer support + - However adjustment actions now only support 'pk' as a lookup field + v13 -> 2021-10-05 - Adds API endpoint to allocate stock items against a BuildOrder - Updates StockItem API with improved filtering against BomItem data diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 62f70ff8fc..de8314b830 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -120,7 +120,7 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): instance.mark_for_deletion() -class StockAdjust(APIView): +class StockAdjustView(generics.CreateAPIView): """ A generic class for handling stocktake actions. @@ -134,174 +134,50 @@ class StockAdjust(APIView): queryset = StockItem.objects.none() - def get_items(self, request): - """ - Return a list of items posted to the endpoint. - Will raise validation errors if the items are not - correctly formatted. - """ + def get_serializer_context(self): + + context = super().get_serializer_context() - _items = [] + context['request'] = self.request - if 'item' in request.data: - _items = [request.data['item']] - elif 'items' in request.data: - _items = request.data['items'] - else: - _items = [] - - if len(_items) == 0: - raise ValidationError(_('Request must contain list of stock items')) - - # List of validated items - self.items = [] - - for entry in _items: - - if not type(entry) == dict: - raise ValidationError(_('Improperly formatted data')) - - # Look for a 'pk' value (use 'id' as a backup) - pk = entry.get('pk', entry.get('id', None)) - - try: - pk = int(pk) - except (ValueError, TypeError): - raise ValidationError(_('Each entry must contain a valid integer primary-key')) - - try: - item = StockItem.objects.get(pk=pk) - except (StockItem.DoesNotExist): - raise ValidationError({ - pk: [_('Primary key does not match valid stock item')] - }) - - if self.allow_missing_quantity and 'quantity' not in entry: - entry['quantity'] = item.quantity - - try: - quantity = Decimal(str(entry.get('quantity', None))) - except (ValueError, TypeError, InvalidOperation): - raise ValidationError({ - pk: [_('Invalid quantity value')] - }) - - if quantity < 0: - raise ValidationError({ - pk: [_('Quantity must not be less than zero')] - }) - - self.items.append({ - 'item': item, - 'quantity': quantity - }) - - # Extract 'notes' field - self.notes = str(request.data.get('notes', '')) + return context -class StockCount(generics.CreateAPIView): +class StockCount(StockAdjustView): """ Endpoint for counting stock (performing a stocktake). """ - queryset = StockItem.objects.none() - serializer_class = StockSerializers.StockCountSerializer - def get_serializer_context(self): - - context = super().get_serializer_context() - context['request'] = self.request - - return context - - -class StockAdd(StockAdjust): +class StockAdd(StockAdjustView): """ Endpoint for adding a quantity of stock to an existing StockItem """ - def post(self, request, *args, **kwargs): - - self.get_items(request) - - n = 0 - - for item in self.items: - if item['item'].add_stock(item['quantity'], request.user, notes=self.notes): - n += 1 - - return Response({"success": "Added stock for {n} items".format(n=n)}) + serializer_class = StockSerializers.StockAddSerializer -class StockRemove(StockAdjust): +class StockRemove(StockAdjustView): """ Endpoint for removing a quantity of stock from an existing StockItem. """ - def post(self, request, *args, **kwargs): - - self.get_items(request) - - n = 0 - - for item in self.items: - - if item['quantity'] > item['item'].quantity: - raise ValidationError({ - item['item'].pk: [_('Specified quantity exceeds stock quantity')] - }) - - if item['item'].take_stock(item['quantity'], request.user, notes=self.notes): - n += 1 - - return Response({"success": "Removed stock for {n} items".format(n=n)}) + serializer_class = StockSerializers.StockRemoveSerializer -class StockTransfer(StockAdjust): +class StockTransfer(StockAdjustView): """ API endpoint for performing stock movements """ - allow_missing_quantity = True - - def post(self, request, *args, **kwargs): - - data = request.data - - try: - location = StockLocation.objects.get(pk=data.get('location', None)) - except (ValueError, StockLocation.DoesNotExist): - raise ValidationError({'location': [_('Valid location must be specified')]}) - - n = 0 - - self.get_items(request) - - for item in self.items: - - if item['quantity'] > item['item'].quantity: - raise ValidationError({ - item['item'].pk: [_('Specified quantity exceeds stock quantity')] - }) - - # If quantity is not specified, move the entire stock - if item['quantity'] in [0, None]: - item['quantity'] = item['item'].quantity - - if item['item'].move(location, self.notes, request.user, quantity=item['quantity']): - n += 1 - - return Response({'success': _('Moved {n} parts to {loc}').format( - n=n, - loc=str(location), - )}) + serializer_class = StockSerializers.StockTransferSerializer class StockLocationList(generics.ListCreateAPIView): - """ API endpoint for list view of StockLocation objects: + """ + API endpoint for list view of StockLocation objects: - GET: Return list of StockLocation objects - POST: Create a new StockLocation diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 801b9a9d94..e6bbc72dd2 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -420,7 +420,8 @@ class StockAdjustmentItemSerializer(serializers.Serializer): many=False, allow_null=False, required=True, - label=_('StockItem primary key value') + label='stock_item', + help_text=_('StockItem primary key value') ) quantity = serializers.DecimalField( @@ -446,7 +447,9 @@ class StockAdjustmentSerializer(serializers.Serializer): notes = serializers.CharField( required=False, - allow_blank=False, + allow_blank=True, + label=_("Notes"), + help_text=_("Stock transaction notes"), ) def validate(self, data): @@ -467,9 +470,7 @@ class StockCountSerializer(StockAdjustmentSerializer): """ def save(self): - """ - Perform the database transactions to count the stock - """ + request = self.context['request'] data = self.validated_data @@ -487,3 +488,105 @@ class StockCountSerializer(StockAdjustmentSerializer): request.user, notes=notes ) + + +class StockAddSerializer(StockAdjustmentSerializer): + """ + Serializer for adding stock to stock item(s) + """ + + def save(self): + + request = self.context['request'] + + data = self.validated_data + notes = data['notes'] + + with transaction.atomic(): + for item in data['items']: + + stock_item = item['pk'] + quantity = item['quantity'] + + stock_item.add_stock( + quantity, + request.user, + notes=notes + ) + + +class StockRemoveSerializer(StockAdjustmentSerializer): + """ + Serializer for removing stock from stock item(s) + """ + + def save(self): + + request = self.context['request'] + + data = self.validated_data + notes = data['notes'] + + with transaction.atomic(): + for item in data['items']: + + stock_item = item['pk'] + quantity = item['quantity'] + + stock_item.take_stock( + quantity, + request.user, + notes=notes + ) + +class StockTransferSerializer(StockAdjustmentSerializer): + """ + Serializer for transferring (moving) stock item(s) + """ + + location = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Location'), + help_text=_('Destination stock location'), + ) + + class Meta: + fields = [ + 'items', + 'notes', + 'location', + ] + + def validate(self, data): + + super().validate(data) + + # TODO: Any specific validation of location field? + + return data + + def save(self): + + request = self.context['request'] + + data = self.validated_data + + items = data['items'] + notes = data['notes'] + location = data['location'] + + with transaction.atomic(): + for item in items: + + stock_item = item['pk'] + quantity = item['quantity'] + + stock_item.move( + location, + notes, + request.user, + quantity=item['quantity'] + ) From 0a2a81582ede69c0f4351f5d57cf3b169b4ca9d3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 22:46:34 +1100 Subject: [PATCH 44/48] Handle case where notes are not provided --- InvenTree/stock/serializers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index e6bbc72dd2..c4fa7fb902 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -475,7 +475,7 @@ class StockCountSerializer(StockAdjustmentSerializer): data = self.validated_data items = data['items'] - notes = data['notes'] + notes = data.get('notes', '') with transaction.atomic(): for item in items: @@ -500,7 +500,7 @@ class StockAddSerializer(StockAdjustmentSerializer): request = self.context['request'] data = self.validated_data - notes = data['notes'] + notes = data.get('notes', '') with transaction.atomic(): for item in data['items']: @@ -525,7 +525,7 @@ class StockRemoveSerializer(StockAdjustmentSerializer): request = self.context['request'] data = self.validated_data - notes = data['notes'] + notes = data.get('notes', '') with transaction.atomic(): for item in data['items']: @@ -575,7 +575,7 @@ class StockTransferSerializer(StockAdjustmentSerializer): data = self.validated_data items = data['items'] - notes = data['notes'] + notes = data.get('notes', '') location = data['location'] with transaction.atomic(): From 6fd1abb07ad53c4ea91108f382ecb2a94d17ad1c Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 22:58:28 +1100 Subject: [PATCH 45/48] Remove unused view class --- InvenTree/stock/views.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 2f602a93e1..c9cc8e8724 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -145,29 +145,6 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView): return super().get(request, *args, **kwargs) -class StockItemNotes(InvenTreeRoleMixin, UpdateView): - """ View for editing the 'notes' field of a StockItem object """ - - context_object_name = 'item' - template_name = 'stock/item_notes.html' - model = StockItem - - role_required = 'stock.view' - - fields = ['notes'] - - def get_success_url(self): - return reverse('stock-item-notes', kwargs={'pk': self.get_object().id}) - - def get_context_data(self, **kwargs): - - ctx = super().get_context_data(**kwargs) - - ctx['editing'] = str2bool(self.request.GET.get('edit', '')) - - return ctx - - class StockLocationEdit(AjaxUpdateView): """ View for editing details of a StockLocation. From 758e402a66780e50580588925df2140e976cf797 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 23:06:12 +1100 Subject: [PATCH 46/48] PEP style fixes --- InvenTree/stock/api.py | 8 +++----- InvenTree/stock/serializers.py | 5 +++-- InvenTree/stock/views.py | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index de8314b830..ad487c7a5a 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -5,7 +5,6 @@ JSON API for the Stock app # -*- coding: utf-8 -*- from __future__ import unicode_literals -from decimal import Decimal, InvalidOperation from datetime import datetime, timedelta from django.utils.translation import ugettext_lazy as _ @@ -17,7 +16,6 @@ from django.db.models import Q from rest_framework import status from rest_framework.serializers import ValidationError -from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import generics, filters, permissions @@ -136,11 +134,11 @@ class StockAdjustView(generics.CreateAPIView): def get_serializer_context(self): - context = super().get_serializer_context() + context = super().get_serializer_context() - context['request'] = self.request + context['request'] = self.request - return context + return context class StockCount(StockAdjustView): diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index c4fa7fb902..c44dffe94f 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -466,7 +466,7 @@ class StockAdjustmentSerializer(serializers.Serializer): class StockCountSerializer(StockAdjustmentSerializer): """ - Serializer for counting stock items + Serializer for counting stock items """ def save(self): @@ -539,6 +539,7 @@ class StockRemoveSerializer(StockAdjustmentSerializer): notes=notes ) + class StockTransferSerializer(StockAdjustmentSerializer): """ Serializer for transferring (moving) stock item(s) @@ -588,5 +589,5 @@ class StockTransferSerializer(StockAdjustmentSerializer): location, notes, request.user, - quantity=item['quantity'] + quantity=quantity ) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index c9cc8e8724..eb5fabcc25 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -7,7 +7,7 @@ from __future__ import unicode_literals from django.core.exceptions import ValidationError from django.views.generic.edit import FormMixin -from django.views.generic import DetailView, ListView, UpdateView +from django.views.generic import DetailView, ListView from django.forms.models import model_to_dict from django.forms import HiddenInput from django.urls import reverse From 95e7cc7a5d2d32164ff4d18a467ee003c112e618 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 6 Oct 2021 08:56:24 +1100 Subject: [PATCH 47/48] Fixes for unit tests --- InvenTree/part/templates/part/part_base.html | 2 +- .../stock/templates/stock/item_base.html | 2 +- InvenTree/stock/templates/stock/location.html | 2 +- InvenTree/stock/test_api.py | 51 +++-- InvenTree/templates/js/translated/stock.js | 189 +++++++++--------- 5 files changed, 120 insertions(+), 126 deletions(-) diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 847baf8ab5..a81f918013 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -372,7 +372,7 @@ { success: function(items) { adjustStock(action, items, { - onSuccess: function() { + success: function() { location.reload(); } }); diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 759732fe6e..3addeacde2 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -561,7 +561,7 @@ function itemAdjust(action) { { success: function(item) { adjustStock(action, [item], { - onSuccess: function() { + success: function() { location.reload(); } }); diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 9a5aeb6a7e..3afaf45635 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -287,7 +287,7 @@ { success: function(items) { adjustStock(action, items, { - onSuccess: function() { + success: function() { location.reload(); } }); diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 21c355fae2..d07c35aaf7 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -513,31 +513,34 @@ class StocktakeTest(StockAPITestCase): # POST with a valid action response = self.post(url, data) - self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST) + + self.assertIn("This field is required", str(response.data["items"])) data['items'] = [{ 'no': 'aa' }] # POST without a PK - response = self.post(url, data) - self.assertContains(response, 'must contain a valid integer primary-key', status_code=status.HTTP_400_BAD_REQUEST) + response = self.post(url, data, expected_code=400) + + self.assertIn('This field is required', str(response.data)) # POST with an invalid PK data['items'] = [{ 'pk': 10 }] - response = self.post(url, data) - self.assertContains(response, 'does not match valid stock item', status_code=status.HTTP_400_BAD_REQUEST) + response = self.post(url, data, expected_code=400) + + self.assertContains(response, 'object does not exist', status_code=status.HTTP_400_BAD_REQUEST) # POST with missing quantity value data['items'] = [{ 'pk': 1234 }] - response = self.post(url, data) - self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST) + response = self.post(url, data, expected_code=400) + self.assertContains(response, 'This field is required', status_code=status.HTTP_400_BAD_REQUEST) # POST with an invalid quantity value data['items'] = [{ @@ -546,7 +549,7 @@ class StocktakeTest(StockAPITestCase): }] response = self.post(url, data) - self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains(response, 'A valid number is required', status_code=status.HTTP_400_BAD_REQUEST) data['items'] = [{ 'pk': 1234, @@ -554,18 +557,7 @@ class StocktakeTest(StockAPITestCase): }] response = self.post(url, data) - self.assertContains(response, 'must not be less than zero', status_code=status.HTTP_400_BAD_REQUEST) - - # Test with a single item - data = { - 'item': { - 'pk': 1234, - 'quantity': '10', - } - } - - response = self.post(url, data) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertContains(response, 'Ensure this value is greater than or equal to 0', status_code=status.HTTP_400_BAD_REQUEST) def test_transfer(self): """ @@ -573,24 +565,27 @@ class StocktakeTest(StockAPITestCase): """ data = { - 'item': { - 'pk': 1234, - 'quantity': 10, - }, + 'items': [ + { + 'pk': 1234, + 'quantity': 10, + } + ], 'location': 1, 'notes': "Moving to a new location" } url = reverse('api-stock-transfer') - response = self.post(url, data) - self.assertContains(response, "Moved 1 parts to", status_code=status.HTTP_200_OK) + # This should succeed + response = self.post(url, data, expected_code=201) # Now try one which will fail due to a bad location data['location'] = 'not a location' - response = self.post(url, data) - self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST) + response = self.post(url, data, expected_code=400) + + self.assertContains(response, 'Incorrect type. Expected pk value', status_code=status.HTTP_400_BAD_REQUEST) class StockItemDeletionTest(StockAPITestCase): diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 17c2598d1b..c6efd88f5f 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -247,7 +247,7 @@ function adjustStock(action, items, options={}) { break; } - var image = item.part_detail.thumbnail || item.part_detail.image || blankImage(); + var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image); var status = stockStatusDisplay(item.status, { classes: 'float-right' @@ -268,14 +268,18 @@ function adjustStock(action, items, options={}) { var actionInput = ''; if (actionTitle != null) { - actionInput = constructNumberInput( - item.pk, + actionInput = constructField( + `items_quantity_${pk}`, { - value: value, + type: 'decimal', min_value: minValue, max_value: maxValue, - read_only: readonly, + value: value, title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}', + required: true, + }, + { + hideLabels: true, } ); } @@ -293,7 +297,7 @@ function adjustStock(action, items, options={}) { html += ` - +
${item.part_detail.full_name}${thumb} ${item.part_detail.full_name} ${quantity}${status} ${location} @@ -319,50 +323,89 @@ function adjustStock(action, items, options={}) { html += `
`; - var modal = createNewModal({ - title: formTitle, - }); + var extraFields = {}; - // Extra fields - var extraFields = { - location: { - label: '{% trans "Location" %}', - help_text: '{% trans "Select destination stock location" %}', - type: 'related field', - required: true, - api_url: `/api/stock/location/`, - model: 'stocklocation', - name: 'location', - }, - notes: { - label: '{% trans "Notes" %}', - help_text: '{% trans "Stock transaction notes" %}', - type: 'string', - name: 'notes', - } - }; - - if (!specifyLocation) { - delete extraFields.location; + if (specifyLocation) { + extraFields.location = {}; } - constructFormBody({}, { - preFormContent: html, + if (action != 'delete') { + extraFields.notes = {}; + } + + constructForm(url, { + method: 'POST', fields: extraFields, + preFormContent: html, confirm: true, confirmMessage: '{% trans "Confirm stock adjustment" %}', - modal: modal, - onSubmit: function(fields) { + title: formTitle, + afterRender: function(fields, opts) { + // Add button callbacks to remove rows + $(opts.modal).find('.button-stock-item-remove').click(function() { + var pk = $(this).attr('pk'); - // "Delete" action gets handled differently + $(opts.modal).find(`#stock_item_${pk}`).remove(); + }); + + // Initialize "location" field + if (specifyLocation) { + initializeRelatedField( + { + name: 'location', + type: 'related field', + model: 'stocklocation', + required: true, + }, + null, + opts + ); + } + }, + onSubmit: function(fields, opts) { + + // Extract data elements from the form + var data = { + items: [], + }; + + if (action != 'delete') { + data.notes = getFormFieldValue('notes', {}, opts); + } + + if (specifyLocation) { + data.location = getFormFieldValue('location', {}, opts); + } + + var item_pk_values = []; + + items.forEach(function(item) { + var pk = item.pk; + + // Does the row exist in the form? + var row = $(opts.modal).find(`#stock_item_${pk}`); + + if (row) { + + item_pk_values.push(pk); + + var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts); + + data.items.push({ + pk: pk, + quantity: quantity, + }); + } + }); + + // Delete action is handled differently if (action == 'delete') { - var requests = []; - items.forEach(function(item) { + item_pk_values.forEach(function(pk) { requests.push( inventreeDelete( - `/api/stock/${item.pk}/`, + `/api/stock/${pk}/`, ) ); }); @@ -370,72 +413,40 @@ function adjustStock(action, items, options={}) { // Wait for *all* the requests to complete $.when.apply($, requests).done(function() { // Destroy the modal window - $(modal).modal('hide'); + $(opts.modal).modal('hide'); - if (options.onSuccess) { - options.onSuccess(); + if (options.success) { + options.success(); } }); return; } - // Data to transmit - var data = { - items: [], + opts.nested = { + 'items': item_pk_values, }; - // Add values for each selected stock item - items.forEach(function(item) { - - var q = getFormFieldValue(item.pk, {}, {modal: modal}); - - if (q != null) { - data.items.push({pk: item.pk, quantity: q}); - } - }); - - // Add in extra field data - for (var field_name in extraFields) { - data[field_name] = getFormFieldValue( - field_name, - fields[field_name], - { - modal: modal, - } - ); - } - inventreePut( url, data, { method: 'POST', - success: function() { + success: function(response) { + // Hide the modal + $(opts.modal).modal('hide'); - // Destroy the modal window - $(modal).modal('hide'); - - if (options.onSuccess) { - options.onSuccess(); + if (options.success) { + options.success(response); } }, error: function(xhr) { switch (xhr.status) { case 400: - - // Handle errors for standard fields - handleFormErrors( - xhr.responseJSON, - extraFields, - { - modal: modal, - } - ); - + handleFormErrors(xhr.responseJSON, fields, opts); break; default: - $(modal).modal('hide'); + $(opts.modal).modal('hide'); showApiError(xhr); break; } @@ -444,18 +455,6 @@ function adjustStock(action, items, options={}) { ); } }); - - // Attach callbacks for the action buttons - $(modal).find('.button-stock-item-remove').click(function() { - var pk = $(this).attr('pk'); - - $(modal).find(`#stock_item_${pk}`).remove(); - }); - - attachToggle(modal); - - $(modal + ' .select2-container').addClass('select-full-width'); - $(modal + ' .select2-container').css('width', '100%'); } @@ -1258,7 +1257,7 @@ function loadStockTable(table, options) { var items = $(table).bootstrapTable('getSelections'); adjustStock(action, items, { - onSuccess: function() { + success: function() { $(table).bootstrapTable('refresh'); } }); From 05de802d1d09b69b7dcfa93e1e6f9f4b2cb370b5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 6 Oct 2021 09:14:06 +1100 Subject: [PATCH 48/48] javascript linting --- InvenTree/templates/js/translated/stock.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index c6efd88f5f..b88f5f1862 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -4,15 +4,12 @@ /* globals attachSelect, - attachToggle, - blankImage, enableField, clearField, clearFieldOptions, closeModal, + constructField, constructFormBody, - constructNumberInput, - createNewModal, getFormFieldValue, global_settings, handleFormErrors, @@ -326,7 +323,7 @@ function adjustStock(action, items, options={}) { var extraFields = {}; if (specifyLocation) { - extraFields.location = {}; + extraFields.location = {}; } if (action != 'delete') {