From 4a0ed4b2a1c69dcc17238855a226f95f5a8bed43 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 19 Oct 2021 22:49:48 +1100 Subject: [PATCH 01/20] Start of API forms for stock item --- InvenTree/stock/serializers.py | 9 +- .../stock/templates/stock/item_base.html | 5 ++ InvenTree/templates/js/translated/forms.js | 12 +++ InvenTree/templates/js/translated/stock.js | 89 +++++++++++++++++++ 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index c44dffe94f..e2781af12f 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -134,7 +134,7 @@ class StockItemSerializer(InvenTreeModelSerializer): tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True, required=False) - quantity = serializers.FloatField() + # quantity = serializers.FloatField() allocated = serializers.FloatField(source='allocation_count', required=False) @@ -142,20 +142,22 @@ class StockItemSerializer(InvenTreeModelSerializer): stale = serializers.BooleanField(required=False, read_only=True) - serial = serializers.CharField(required=False) + # serial = serializers.CharField(required=False) required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False) purchase_price = InvenTreeMoneySerializer( label=_('Purchase Price'), max_digits=19, decimal_places=4, - allow_null=True + allow_null=True, + help_text=_('Purchase price of this stock item'), ) purchase_price_currency = serializers.ChoiceField( choices=currency_code_mappings(), default=currency_code_default, label=_('Currency'), + help_text=_('Purchase currency of this stock item'), ) purchase_price_string = serializers.SerializerMethodField() @@ -197,6 +199,7 @@ class StockItemSerializer(InvenTreeModelSerializer): 'belongs_to', 'build', 'customer', + 'delete_on_deplete', 'expired', 'expiry_date', 'in_stock', diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index b7019ff887..97b94bdd70 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -150,6 +150,7 @@
  • {% trans "Duplicate stock item" %}
  • {% endif %}
  • {% trans "Edit stock item" %}
  • +
  • {% trans "Edit stock item" %}
  • {% if user.is_staff or roles.stock.delete %} {% if item.can_delete %}
  • {% trans "Delete stock item" %}
  • @@ -520,6 +521,10 @@ $("#stock-edit").click(function () { ); }); +$('#stock-edit-2').click(function() { + editStockItem({{ item.pk }}); +}); + $('#stock-edit-status').click(function () { constructForm('{% url "api-stock-detail" item.pk %}', { diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 1bfe196286..07e38565cd 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -179,6 +179,7 @@ function constructChangeForm(fields, options) { // Request existing data from the API endpoint $.ajax({ url: options.url, + data: options.params || {}, type: 'GET', contentType: 'application/json', dataType: 'json', @@ -194,6 +195,17 @@ function constructChangeForm(fields, options) { fields[field].value = data[field]; } } + + // An optional function can be provided to process the returned results, + // before they are rendered to the form + if (options.processResults) { + var processed = options.processResults(data, fields, options); + + // If the processResults function returns data, it will be stored + if (processed) { + data = processed; + } + } // Store the entire data object options.instance = data; diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 67c50bffef..2f885477a5 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -68,6 +68,95 @@ function locationFields() { } +function stockItemFields(options={}) { + var fields = { + part: {}, + supplier_part: { + filters: { + part_detail: true, + supplier_detail: true, + }, + adjustFilters: function(query, opts) { + var part = getFormFieldValue('part', {}, opts); + + if (part) { + query.part = part; + } + + return query; + } + }, + serial: {}, + status: {}, + expiry_date: {}, + batch: {}, + purchase_price: {}, + purchase_price_currency: {}, + packaging: {}, + link: {}, + delete_on_deplete: {}, + // owner: {}, + }; + + // Remove stock expiry fields if feature is not enabled + if (!global_settings.STOCK_ENABLE_EXPIRY) { + delete fields['expiry_date']; + } + + return fields; +} + + +function stockItemGroups(options={}) { + return { + + }; +} + + +/* + * Launch a modal form to edit a given StockItem + */ +function editStockItem(pk, options={}) { + + var url = `/api/stock/${pk}/`; + + var fields = stockItemFields(options); + + // Prevent editing of the "part" + fields.part.hidden = true; + + var groups = stockItemGroups(options); + + constructForm(url, { + fields: fields, + groups: groups, + title: '{% trans "Edit Stock Item" %}', + params: { + part_detail: true, + supplier_part_detail: true, + }, + processResults: function(data, fields, options) { + // Callback when StockItem data is received from server + + if (data.part_detail.trackable) { + delete options.fields.delete_on_deplete; + } else { + // Remove serial number field if part is not trackable + delete options.fields.serial; + } + + // Remove pricing fields if part is not purchaseable + if (!data.part_detail.purchaseable) { + delete options.fields.supplier_part; + delete options.fields.purchase_price; + delete options.fields.purchase_price_currency; + } + } + }); +} + + /* Stock API functions * Requires api.js to be loaded first */ From d3b1ecd65e2a0611de9363a1cf8e6af03c6963c2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 20 Oct 2021 23:44:01 +1100 Subject: [PATCH 02/20] Add "owner" field --- InvenTree/stock/serializers.py | 7 ++++--- InvenTree/templates/js/translated/stock.js | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index e2781af12f..0b99ed9c02 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -58,15 +58,15 @@ class StockItemSerializerBrief(InvenTreeModelSerializer): class Meta: model = StockItem fields = [ - 'pk', - 'uid', 'part', 'part_name', - 'supplier_part', + 'pk', 'location', 'location_name', 'quantity', 'serial', + 'supplier_part', + 'uid', ] @@ -208,6 +208,7 @@ class StockItemSerializer(InvenTreeModelSerializer): 'location', 'location_detail', 'notes', + 'owner', 'packaging', 'part', 'part_detail', diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 2f885477a5..2bc562cb5a 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -94,8 +94,8 @@ function stockItemFields(options={}) { purchase_price_currency: {}, packaging: {}, link: {}, + owner: {}, delete_on_deplete: {}, - // owner: {}, }; // Remove stock expiry fields if feature is not enabled @@ -103,6 +103,11 @@ function stockItemFields(options={}) { delete fields['expiry_date']; } + // Remove ownership field if feature is not enanbled + if (!global_settings.STOCK_OWNERSHIP_CONTROL) { + delete fields['owner']; + } + return fields; } From c3c4aca829eb3a33934f74dafcdedaa8acdfa0c4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 19:29:26 +1100 Subject: [PATCH 03/20] Fix action buttons for stock-item detail page --- .../stock/templates/stock/item_base.html | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index d225e8734e..82d8625256 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -22,40 +22,39 @@ {% url 'admin:stock_stockitem_change' item.pk as url %} {% include "admin_button.html" with url=url %} {% endif %} -
    -
    +
    {% if barcodes %} - {% endif %} -
    - + @@ -63,37 +62,37 @@ {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} {% if roles.stock.change and not item.is_building %} -
    - +
    + @@ -102,19 +101,19 @@ {% if roles.stock.change and not item.is_building %}
    - + @@ -123,6 +122,8 @@ {% endif %}
    +{% endblock %} + {% block thumbnail %} {% endblock %} From d357e982f5ed6cc4d3d795a8a552a424b60db183 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 19:43:17 +1100 Subject: [PATCH 04/20] Revert stock-item detail template to mater --- .../stock/templates/stock/item_base.html | 164 ++++++++---------- 1 file changed, 75 insertions(+), 89 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 82d8625256..5a58e2e04f 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -22,106 +22,96 @@ {% url 'admin:stock_stockitem_change' item.pk as url %} {% include "admin_button.html" with url=url %} {% endif %} - +{% if barcodes %} +
    - + +
    - +{% endif %} +
    - - {% if barcodes %} - -
    - + + +
    + + +{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} + {% if roles.stock.change and not item.is_building %} +
    +
    {% endif %} - -
    - + + {% if roles.stock.change and not item.is_building %} +
    +
    - - - {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} - {% if roles.stock.change and not item.is_building %} -
    - - -
    - {% endif %} - - {% if roles.stock.change and not item.is_building %} -
    - - -
    - {% endif %} {% endif %} -
    - +{% endif %} {% endblock %} {% block thumbnail %} @@ -491,10 +481,6 @@ $("#stock-edit").click(function () { ); }); -$('#stock-edit-2').click(function() { - editStockItem({{ item.pk }}); -}); - $('#stock-edit-status').click(function () { constructForm('{% url "api-stock-detail" item.pk %}', { From 4c8bc9580c6cf790c99c87300a80201bd3c04122 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 19:51:46 +1100 Subject: [PATCH 05/20] stock-item-edit is looking OK now --- .../stock/templates/stock/item_base.html | 12 ++--- InvenTree/stock/test_views.py | 16 +++--- InvenTree/stock/urls.py | 1 - InvenTree/templates/js/translated/stock.js | 51 ++++++++++--------- 4 files changed, 37 insertions(+), 43 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 5a58e2e04f..525b6767d6 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -471,14 +471,10 @@ $("#stock-duplicate").click(function() { }); }); -$("#stock-edit").click(function () { - launchModalForm( - "{% url 'stock-item-edit' item.id %}", - { - reload: true, - submit_text: '{% trans "Save" %}', - } - ); +$('#stock-edit').click(function() { + editStockItem({{ item.pk }}, { + reload: true, + }); }); $('#stock-edit-status').click(function () { diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 9494598430..6d22c7e4bb 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -105,15 +105,6 @@ class StockItemTest(StockViewTestCase): response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertEqual(response.status_code, 200) - def test_edit_item(self): - # Test edit view for StockItem - response = self.client.get(reverse('stock-item-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Test with a non-purchaseable part - response = self.client.get(reverse('stock-item-edit', args=(100,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - def test_create_item(self): """ Test creation of StockItem @@ -273,11 +264,15 @@ class StockOwnershipTest(StockViewTestCase): HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertContains(response, '"form_valid": true', status_code=200) + """ + TODO: Refactor this following test to use the new API form # Set ownership on existing item (and change location) response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), {'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertContains(response, '"form_valid": true', status_code=200) + """ # Logout self.client.logout() @@ -294,6 +289,8 @@ class StockOwnershipTest(StockViewTestCase): location = StockLocation.objects.get(pk=test_location_id) self.assertEqual(location.owner, user_group_owner) + """ + TODO: Refactor this following test to use the new API form # Test item edit response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), {'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk}, @@ -302,6 +299,7 @@ class StockOwnershipTest(StockViewTestCase): # Make sure the item's owner is unchanged item = StockItem.objects.get(pk=test_item_id) self.assertEqual(item.owner, user_as_owner) + """ # Create new parent location parent_location = { diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 434acde84e..d3e7090652 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -22,7 +22,6 @@ location_urls = [ ] stock_item_detail_urls = [ - url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'), url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'), url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 9268dcefc9..174e675426 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -131,34 +131,35 @@ function editStockItem(pk, options={}) { // Prevent editing of the "part" fields.part.hidden = true; - var groups = stockItemGroups(options); + options.groups = stockItemGroups(options); - constructForm(url, { - fields: fields, - groups: groups, - title: '{% trans "Edit Stock Item" %}', - params: { - part_detail: true, - supplier_part_detail: true, - }, - processResults: function(data, fields, options) { - // Callback when StockItem data is received from server + options.fields = fields; + options.title = '{% trans "Edit Stock Item" %}'; + + // Query parameters for retrieving stock item data + options.params = { + part_detail: true, + supplier_part_detail: true, + }; - if (data.part_detail.trackable) { - delete options.fields.delete_on_deplete; - } else { - // Remove serial number field if part is not trackable - delete options.fields.serial; - } - - // Remove pricing fields if part is not purchaseable - if (!data.part_detail.purchaseable) { - delete options.fields.supplier_part; - delete options.fields.purchase_price; - delete options.fields.purchase_price_currency; - } + // Augment the rendered form when we receive information about the StockItem + options.processResults = function(data, fields, options) { + if (data.part_detail.trackable) { + delete options.fields.delete_on_deplete; + } else { + // Remove serial number field if part is not trackable + delete options.fields.serial; } - }); + + // Remove pricing fields if part is not purchaseable + if (!data.part_detail.purchaseable) { + delete options.fields.supplier_part; + delete options.fields.purchase_price; + delete options.fields.purchase_price_currency; + } + }; + + constructForm(url, options); } From 0d9c08b49c56ee05cb794f9e0a933ff726c7bf5a Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 19:58:25 +1100 Subject: [PATCH 06/20] StockLocationEdit --- InvenTree/stock/templates/stock/location.html | 9 +++--- InvenTree/stock/test_views.py | 32 ------------------- InvenTree/stock/urls.py | 1 - InvenTree/stock/views.py | 4 +++ InvenTree/templates/js/translated/stock.js | 18 +++++++++-- 5 files changed, 24 insertions(+), 40 deletions(-) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 7490d262bd..d3cc407298 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -243,12 +243,11 @@ }); {% if location %} + $('#location-edit').click(function() { - launchModalForm("{% url 'stock-location-edit' location.id %}", - { - reload: true - }); - return false; + editStockLocation({{ location.id }}, { + reload: true, + }); }); $('#location-delete').click(function() { diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 6d22c7e4bb..64d0ac42d5 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -66,10 +66,6 @@ class StockListTest(StockViewTestCase): class StockLocationTest(StockViewTestCase): """ Tests for StockLocation views """ - def test_location_edit(self): - response = self.client.get(reverse('stock-location-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - def test_qr_code(self): # Request the StockLocation QR view response = self.client.get(reverse('stock-location-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') @@ -258,12 +254,6 @@ class StockOwnershipTest(StockViewTestCase): # Enable ownership control self.enable_ownership() - # Set ownership on existing location - response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)), - {'name': 'Office', 'owner': user_group_owner.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - """ TODO: Refactor this following test to use the new API form # Set ownership on existing item (and change location) @@ -280,15 +270,6 @@ class StockOwnershipTest(StockViewTestCase): # Login with new user self.client.login(username='john', password='custom123') - # Test location edit - response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)), - {'name': 'Office', 'owner': new_user_group_owner.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - # Make sure the location's owner is unchanged - location = StockLocation.objects.get(pk=test_location_id) - self.assertEqual(location.owner, user_group_owner) - """ TODO: Refactor this following test to use the new API form # Test item edit @@ -370,16 +351,3 @@ class StockOwnershipTest(StockViewTestCase): # Logout self.client.logout() - - # Login with admin - self.client.login(username='username', password='password') - - # Switch owner of location - response = self.client.post(reverse('stock-location-edit', args=(location_created.pk,)), - {'name': new_location['name'], 'owner': user_group_owner.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - - # Check that owner was updated for item in this location - stock_item = StockItem.objects.all().last() - self.assertEqual(stock_item.owner, user_group_owner) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index d3e7090652..58b76b9bf7 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -11,7 +11,6 @@ location_urls = [ url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'), url(r'^(?P\d+)/', include([ - url(r'^edit/?', views.StockLocationEdit.as_view(), name='stock-location-edit'), url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'), url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index eb5fabcc25..583ad705db 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -149,6 +149,10 @@ class StockLocationEdit(AjaxUpdateView): """ View for editing details of a StockLocation. This view is used with the EditStockLocationForm to deliver a modal form to the web view + + TODO: Remove this code as location editing has been migrated to the API forms + - Have to still validate that all form functionality (as below) as been ported + """ model = StockLocation diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 174e675426..8d0637238c 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -51,13 +51,14 @@ loadStockTestResultsTable, loadStockTrackingTable, loadTableFilters, - locationFields, removeStockRow, + stockItemFields, + stockLocationFields, stockStatusCodes, */ -function locationFields() { +function stockLocationFields(options={}) { return { parent: { help_text: '{% trans "Parent stock location" %}', @@ -68,6 +69,19 @@ function locationFields() { } +/* + * Launch an API form to edit a stock location + */ +function editStockLocation(pk, options={}) { + + var url = `/api/stock/location/${pk}/`; + + options.fields = stockLocationFields(options); + + constructForm(url, options); +} + + function stockItemFields(options={}) { var fields = { part: {}, From ef305032c962b6b4f97e036a1d7eb587a3c24e16 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 20:04:25 +1100 Subject: [PATCH 07/20] Create new stock location via API forms --- InvenTree/stock/templates/stock/location.html | 25 +++----- InvenTree/stock/test_views.py | 58 ------------------- InvenTree/stock/urls.py | 2 - InvenTree/stock/views.py | 4 ++ InvenTree/templates/js/translated/stock.js | 22 ++++++- 5 files changed, 32 insertions(+), 79 deletions(-) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index d3cc407298..78b82b5093 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -222,24 +222,13 @@ }); $('#location-create').click(function () { - launchModalForm("{% url 'stock-location-create' %}", - { - data: { - {% if location %} - location: {{ location.id }} - {% endif %} - }, - follow: true, - secondary: [ - { - field: 'parent', - label: '{% trans "New Location" %}', - title: '{% trans "Create new location" %}', - url: "{% url 'stock-location-create' %}", - }, - ] - }); - return false; + + createStockLocation({ + {% if location %} + parent: {{ location.pk }}, + {% endif %} + follow: true, + }); }); {% if location %} diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 64d0ac42d5..3aacf8a139 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -63,32 +63,6 @@ class StockListTest(StockViewTestCase): self.assertEqual(response.status_code, 200) -class StockLocationTest(StockViewTestCase): - """ Tests for StockLocation views """ - - def test_qr_code(self): - # Request the StockLocation QR view - response = self.client.get(reverse('stock-location-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Test for an invalid StockLocation - response = self.client.get(reverse('stock-location-qr', args=(999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_create(self): - # Test StockLocation creation view - response = self.client.get(reverse('stock-location-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Create with a parent - response = self.client.get(reverse('stock-location-create'), {'location': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Create with an invalid parent - response = self.client.get(reverse('stock-location-create'), {'location': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - class StockItemTest(StockViewTestCase): """" Tests for StockItem views """ @@ -289,38 +263,6 @@ class StockOwnershipTest(StockViewTestCase): 'owner': new_user_group_owner.pk, } - # Create new parent location - response = self.client.post(reverse('stock-location-create'), - parent_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - - # Retrieve created location - parent_location = StockLocation.objects.get(name=parent_location['name']) - - # Create new child location - new_location = { - 'name': 'Upper Left Drawer', - 'description': 'John\'s desk - Upper left drawer', - } - - # Try to create new location with neither parent or owner - response = self.client.post(reverse('stock-location-create'), - new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": false', status_code=200) - - # Try to create new location with invalid owner - new_location['parent'] = parent_location.id - new_location['owner'] = user_group_owner.pk - response = self.client.post(reverse('stock-location-create'), - new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": false', status_code=200) - - # Try to create new location with valid owner - new_location['owner'] = new_user_group_owner.pk - response = self.client.post(reverse('stock-location-create'), - new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - # Retrieve created location location_created = StockLocation.objects.get(name=new_location['name']) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 58b76b9bf7..5441101aa1 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -8,8 +8,6 @@ from stock import views location_urls = [ - url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'), - url(r'^(?P\d+)/', include([ url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'), url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 583ad705db..44c1a824bd 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -931,6 +931,10 @@ class StockLocationCreate(AjaxCreateView): """ View for creating a new StockLocation A parent location (another StockLocation object) can be passed as a query parameter + + TODO: Remove this class entirely, as it has been migrated to the API forms + - Still need to check that all the functionality (as below) has been implemented + """ model = StockLocation diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 8d0637238c..a987348d8b 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -59,13 +59,19 @@ function stockLocationFields(options={}) { - return { + var fields = { parent: { help_text: '{% trans "Parent stock location" %}', }, name: {}, description: {}, }; + + if (options.parent) { + fields.parent.value = options.parent; + } + + return fields; } @@ -82,6 +88,20 @@ function editStockLocation(pk, options={}) { } +/* + * Launch an API form to create a new stock location + */ +function createStockLocation(options={}) { + + var url = '{% url "api-location-list" %}'; + + options.method = 'POST'; + options.fields = stockLocationFields(options); + + constructForm(url, options); +} + + function stockItemFields(options={}) { var fields = { part: {}, From d8e3c40f78d4536a46be16bfca2006f0306c2288 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 23:04:10 +1100 Subject: [PATCH 08/20] Implementing more complex behaviour for StockItem creation form --- InvenTree/templates/js/translated/forms.js | 16 ++- InvenTree/templates/js/translated/stock.js | 160 ++++++++++----------- 2 files changed, 94 insertions(+), 82 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index dd6e2cb291..59df238f51 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -25,7 +25,9 @@ */ /* exported - setFormGroupVisibility + hideFormInput, + setFormGroupVisibility, + showFormInput, */ /** @@ -1248,6 +1250,18 @@ function initializeGroups(fields, options) { } } +// Hide a form input +function hideFormInput(name, options) { + $(options.modal).find(`#div_id_${name}`).hide(); +} + + +// Show a form input +function showFormInput(name, options) { + $(options.modal).find(`#div_id_${name}`).show(); +} + + // Hide a form group function hideFormGroup(group, options) { $(options.modal).find(`#form-panel-${group}`).hide(); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index a987348d8b..7cf839496b 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -97,6 +97,7 @@ function createStockLocation(options={}) { options.method = 'POST'; options.fields = stockLocationFields(options); + options.title = '{% trans "New Stock Location" %}'; constructForm(url, options); } @@ -104,8 +105,28 @@ function createStockLocation(options={}) { function stockItemFields(options={}) { var fields = { - part: {}, + part: { + onSelect: function(data, field, opts) { + // Callback when a new "part" is selected + + // If we are "creating" a new stock item + if (options.create) { + // If a "trackable" part is selected, enable serial number field + if (data.trackable) { + showFormInput('serial_numbers', opts); + } else { + updateFieldValue('serial_numbers', '', {}, opts); + hideFormInput('serial_numbers', opts); + } + } + + // TODO: Hide "purchase price" fields for non purchaseable parts! + + // TODO: Update "location" based on "default_location" returned + } + }, supplier_part: { + icon: 'fa-building', filters: { part_detail: true, supplier_detail: true, @@ -120,18 +141,49 @@ function stockItemFields(options={}) { return query; } }, - serial: {}, + location: { + icon: 'fa-sitemap', + }, + quantity: { + help_text: '{% trans "Enter initial quantity for this stock item" %}', + }, + serial_numbers: { + icon: 'fa-hashtag', + type: 'string', + label: '{% trans "Serial Numbers" %}', + help_text: '{% trans "Enter serial numbers for new stock (or leave blank)" %}', + required: false, + }, + serial: { + icon: 'fa-hashtag', + }, status: {}, expiry_date: {}, batch: {}, - purchase_price: {}, + purchase_price: { + icon: 'fa-dollar-sign', + }, purchase_price_currency: {}, - packaging: {}, - link: {}, + packaging: { + icon: 'fa-box', + }, + link: { + icon: 'fa-link', + }, owner: {}, delete_on_deplete: {}, }; + if (options.create) { + // Use "serial numbers" field when creating a new stock item + delete fields['serial']; + } else { + // These fields cannot be edited once the stock item has been created + delete fields['serial_numbers']; + delete fields['quantity']; + delete fields['location']; + } + // Remove stock expiry fields if feature is not enabled if (!global_settings.STOCK_ENABLE_EXPIRY) { delete fields['expiry_date']; @@ -160,14 +212,14 @@ function editStockItem(pk, options={}) { var url = `/api/stock/${pk}/`; - var fields = stockItemFields(options); - // Prevent editing of the "part" fields.part.hidden = true; + options.create = false; + + options.fields = stockItemFields(options); options.groups = stockItemGroups(options); - options.fields = fields; options.title = '{% trans "Edit Stock Item" %}'; // Query parameters for retrieving stock item data @@ -197,6 +249,25 @@ function editStockItem(pk, options={}) { } +/* + * Launch an API form to contsruct a new stock item + */ +function createNewStockItem(options={}) { + + var url = '{% url "api-stock-list" %}'; + + options.title = '{% trans "New Stock Item" %}'; + options.method = 'POST'; + + options.create = true; + + options.fields = stockItemFields(options); + options.groups = stockItemGroups(options); + + constructForm(url, options); +} + + /* Stock API functions * Requires api.js to be loaded first */ @@ -1929,79 +2000,6 @@ function loadStockTrackingTable(table, options) { } -function createNewStockItem(options) { - /* Launch a modal form to create a new stock item. - * - * This is really just a helper function which calls launchModalForm, - * but it does get called a lot, so here we are ... - */ - - // Add in some funky options - - options.callback = [ - { - field: 'part', - action: function(value) { - - if (!value) { - // No part chosen - - clearFieldOptions('supplier_part'); - enableField('serial_numbers', false); - enableField('purchase_price_0', false); - enableField('purchase_price_1', false); - - return; - } - - // Reload options for supplier part - reloadFieldOptions( - 'supplier_part', - { - url: '{% url "api-supplier-part-list" %}', - params: { - part: value, - pretty: true, - }, - text: function(item) { - return item.pretty_name; - } - } - ); - - // Request part information from the server - inventreeGet( - `/api/part/${value}/`, {}, - { - success: function(response) { - - // Disable serial number field if the part is not trackable - enableField('serial_numbers', response.trackable); - clearField('serial_numbers'); - - enableField('purchase_price_0', response.purchaseable); - enableField('purchase_price_1', response.purchaseable); - - // Populate the expiry date - if (response.default_expiry <= 0) { - // No expiry date - clearField('expiry_date'); - } else { - var expiry = moment().add(response.default_expiry, 'days'); - - setFieldValue('expiry_date', expiry.format('YYYY-MM-DD')); - } - } - } - ); - } - }, - ]; - - launchModalForm('{% url "stock-item-create" %}', options); -} - - function loadInstalledInTable(table, options) { /* * Display a table showing the stock items which are installed in this stock item. From aaf27d409818c2b9a65ac48556a063bfdbf18adf Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 07:27:58 +1100 Subject: [PATCH 09/20] Adds new buttons to create a new stock item --- .../company/templates/company/supplier_part.html | 10 +++++++++- .../templates/order/purchase_order_detail.html | 2 +- InvenTree/part/templates/part/detail.html | 15 +++++++++++++-- InvenTree/stock/templates/stock/location.html | 10 +++++++++- InvenTree/templates/stock_table.html | 12 ++---------- 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 52742cf488..d3bee4e797 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -134,7 +134,15 @@ src="{% static 'img/blank_image.png' %}"
    -

    {% trans "Supplier Part Stock" %}

    + +

    {% trans "Supplier Part Stock" %}

    + {% include "spacer.html" %} +
    + +
    +
    {% include "stock_table.html" %} diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index b0d9d7d301..257707347a 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -50,7 +50,7 @@

    {% trans "Received Items" %}

    - {% include "stock_table.html" with prevent_new_stock=True %} + {% include "stock_table.html" %}
    diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 145b5bfb35..3c75b128da 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -95,7 +95,15 @@
    -

    {% trans "Part Stock" %}

    +
    +

    {% trans "Part Stock" %}

    + {% include "spacer.html" %} +
    + +
    +
    {% if part.is_template %} @@ -851,11 +859,14 @@ }); onPanelLoad("part-stock", function() { - $('#add-stock-item').click(function () { + $('#new-stock-item').click(function () { createNewStockItem({ reload: true, data: { part: {{ part.id }}, + {% if part.default_location %} + location: {{ part.default_location.pk }}, + {% endif %} } }); }); diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 78b82b5093..bf3c5d1648 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -140,7 +140,15 @@
    -

    {% trans "Stock Items" %}

    +
    +

    {% trans "Stock Items" %}

    + {% include "spacer.html" %} +
    + +
    +
    {% include "stock_table.html" %} diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html index 912d2e16a5..71c6734818 100644 --- a/InvenTree/templates/stock_table.html +++ b/InvenTree/templates/stock_table.html @@ -10,17 +10,10 @@
    -
    +
    - - {% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %} - {% if not read_only and not prevent_new_stock and roles.stock.add %} - - {% endif %} {% if barcodes %}
    @@ -46,7 +39,7 @@
    {% if not read_only %} {% if roles.stock.change or roles.stock.delete %} -
    +
    @@ -66,7 +59,6 @@
    {% endif %} {% endif %} - {% endif %} {% include "filter_list.html" with id="stock" %}
    From ad4c4f2a6d3c030f26e069b76c85ef5bb7179370 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 07:28:21 +1100 Subject: [PATCH 10/20] Stock item duplication now works with the API forms --- .../stock/templates/stock/item_base.html | 6 ++-- InvenTree/templates/js/translated/stock.js | 28 +++++++++++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 525b6767d6..c2220776d7 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -463,11 +463,9 @@ $("#print-label").click(function() { {% if roles.stock.change %} $("#stock-duplicate").click(function() { - createNewStockItem({ + // Duplicate a stock item + duplicateStockItem({{ item.pk }}, { follow: true, - data: { - copy: {{ item.id }}, - } }); }); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 7cf839496b..e968da3769 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -106,6 +106,8 @@ function createStockLocation(options={}) { function stockItemFields(options={}) { var fields = { part: { + // Hide the part field unless we are "creating" a new stock item + hidden: !options.create, onSelect: function(data, field, opts) { // Callback when a new "part" is selected @@ -205,6 +207,29 @@ function stockItemGroups(options={}) { } +/* + * Launch a modal form to duplicate a given StockItem + */ +function duplicateStockItem(pk, options) { + + // First, we need the StockItem informatino + inventreeGet(`/api/stock/${pk}/`, {}, { + success: function(data) { + + options.create = true; + + options.data = data; + options.method = 'POST'; + options.fields = stockItemFields(options); + options.groups = stockItemGroups(options); + options.title = '{% trans "Duplicate Stock Item" %}'; + + constructForm('{% url "api-stock-list" %}', options); + } + }); +} + + /* * Launch a modal form to edit a given StockItem */ @@ -212,9 +237,6 @@ function editStockItem(pk, options={}) { var url = `/api/stock/${pk}/`; - // Prevent editing of the "part" - fields.part.hidden = true; - options.create = false; options.fields = stockItemFields(options); From 2b69d9c2afe085c7a7014fec3e8f9dcfed9a8df6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 07:41:47 +1100 Subject: [PATCH 11/20] Correctly serialize stock when creating via the API --- InvenTree/stock/api.py | 71 +++++++++++++++++----- InvenTree/templates/js/translated/stock.js | 8 ++- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 1207312688..143481acd5 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -7,9 +7,11 @@ from __future__ import unicode_literals from datetime import datetime, timedelta +from django.core.exceptions import ValidationError as DjangoValidationError from django.conf.urls import url, include from django.http import JsonResponse from django.db.models import Q +from django.db import transaction from rest_framework import status from rest_framework.serializers import ValidationError @@ -39,7 +41,7 @@ import common.models import stock.serializers as StockSerializers -from InvenTree.helpers import str2bool, isNull +from InvenTree.helpers import str2bool, isNull, extract_serial_numbers from InvenTree.api import AttachmentMixin from InvenTree.filters import InvenTreeOrderingFilter @@ -380,28 +382,67 @@ class StockList(generics.ListCreateAPIView): """ user = request.user + data = request.data - serializer = self.get_serializer(data=request.data) + serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) - item = serializer.save() + # Check if a set of serial numbers was provided + serial_numbers = data.get('serial_numbers', '') - # A location was *not* specified - try to infer it - if 'location' not in request.data: - item.location = item.part.get_default_location() + quantity = data['quantity'] - # An expiry date was *not* specified - try to infer it! - if 'expiry_date' not in request.data: + notes = data.get('notes', '') - if item.part.default_expiry > 0: - item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry) + serials = None - # Finally, save the item - item.save(user=user) + if serial_numbers: + # If serial numbers are specified, check that they match! + try: + serials = extract_serial_numbers(serial_numbers, data['quantity']) + except DjangoValidationError as e: + raise ValidationError({ + 'quantity': e.messages, + 'serial_numbers': e.messages, + }) - # Return a response - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + with transaction.atomic(): + + # Create an initial stock item + item = serializer.save() + + # A location was *not* specified - try to infer it + if 'location' not in data: + item.location = item.part.get_default_location() + + # An expiry date was *not* specified - try to infer it! + if 'expiry_date' not in data: + + if item.part.default_expiry > 0: + item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry) + + # Finally, save the item (with user information) + item.save(user=user) + + # Serialize the stock, if required + if serials: + try: + item.serializeStock( + quantity, + serials, + user, + notes=notes, + location=item.location, + ) + except DjangoValidationError as e: + raise ValidationError({ + 'quantity': e.messages, + 'serial_numbers': e.messages, + }) + + # Return a response + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def list(self, request, *args, **kwargs): """ diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index e968da3769..d4a250c332 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -216,12 +216,16 @@ function duplicateStockItem(pk, options) { inventreeGet(`/api/stock/${pk}/`, {}, { success: function(data) { - options.create = true; + // Do not duplicate the serial number + delete data['serial']; options.data = data; - options.method = 'POST'; + + options.create = true; options.fields = stockItemFields(options); options.groups = stockItemGroups(options); + + options.method = 'POST'; options.title = '{% trans "Duplicate Stock Item" %}'; constructForm('{% url "api-stock-list" %}', options); From be7b224f14d95deb8f917bf3990f3fa24b160fb8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 10:12:42 +1100 Subject: [PATCH 12/20] Adds API endpoint for serialization of stock items --- InvenTree/stock/api.py | 28 +++- InvenTree/stock/serializers.py | 153 +++++++++++++++--- .../stock/templates/stock/item_base.html | 16 +- InvenTree/stock/test_views.py | 40 ----- InvenTree/stock/urls.py | 1 - InvenTree/stock/views.py | 83 ---------- InvenTree/templates/js/translated/stock.js | 27 ++++ 7 files changed, 198 insertions(+), 150 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 143481acd5..5ca552dd8e 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -101,6 +101,27 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): instance.mark_for_deletion() +class StockItemSerialize(generics.CreateAPIView): + """ + API endpoint for serializing a stock item + """ + + queryset = StockItem.objects.none() + serializer_class = StockSerializers.SerializeStockItemSerializer + + def get_serializer_context(self): + + context = super().get_serializer_context() + context['request'] = self.request + + try: + context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return context + + class StockAdjustView(generics.CreateAPIView): """ A generic class for handling stocktake actions. @@ -1126,8 +1147,11 @@ stock_api_urls = [ url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'), ])), - # Detail for a single stock item - url(r'^(?P\d+)/', StockDetail.as_view(), name='api-stock-detail'), + # Detail views for a single stock item + url(r'^(?P\d+)/', include([ + url(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'), + url(r'^.*$', StockDetail.as_view(), name='api-stock-detail'), + ])), # Anything else url(r'^.*$', StockList.as_view(), name='api-stock-list'), diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 0b99ed9c02..8513fa8740 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -9,6 +9,7 @@ from decimal import Decimal from datetime import datetime, timedelta 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.functions import Coalesce from django.db.models import Case, When, Value @@ -27,14 +28,15 @@ from .models import StockItemTestResult import common.models from common.settings import currency_code_default, currency_code_mappings - from company.serializers import SupplierPartSerializer + +import InvenTree.helpers +import InvenTree.serializers + from part.serializers import PartBriefSerializer -from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer, InvenTreeMoneySerializer -from InvenTree.serializers import InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField -class LocationBriefSerializer(InvenTreeModelSerializer): +class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Provides a brief serializer for a StockLocation object """ @@ -48,7 +50,7 @@ class LocationBriefSerializer(InvenTreeModelSerializer): ] -class StockItemSerializerBrief(InvenTreeModelSerializer): +class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): """ Brief serializers for a StockItem """ location_name = serializers.CharField(source='location', read_only=True) @@ -70,7 +72,7 @@ class StockItemSerializerBrief(InvenTreeModelSerializer): ] -class StockItemSerializer(InvenTreeModelSerializer): +class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Serializer for a StockItem: - Includes serialization for the linked part @@ -146,7 +148,7 @@ class StockItemSerializer(InvenTreeModelSerializer): required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False) - purchase_price = InvenTreeMoneySerializer( + purchase_price = InvenTree.serializers.InvenTreeMoneySerializer( label=_('Purchase Price'), max_digits=19, decimal_places=4, allow_null=True, @@ -247,14 +249,127 @@ class StockItemSerializer(InvenTreeModelSerializer): ] -class StockQuantitySerializer(InvenTreeModelSerializer): +class SerializeStockItemSerializer(serializers.Serializer): + """ + A DRF serializer for "serializing" a StockItem. + + (Sorry for the confusing naming...) + + Here, "serializing" means splitting out a single StockItem, + into multiple single-quantity items with an assigned serial number + + Note: The base StockItem object is provided to the serializer context + """ class Meta: - model = StockItem - fields = ('quantity',) + fields = [ + 'quantity', + 'serial_numbers', + 'destination', + 'notes', + ] + + quantity = serializers.IntegerField( + min_value=0, + required=True, + label=_('Quantity'), + help_text=_('Enter number of stock items to serialize'), + ) + + def validate_quantity(self, quantity): + + item = self.context['item'] + + if quantity < 0: + raise ValidationError(_("Quantity must be greater than zero")) + + if quantity > item.quantity: + q = item.quantity + raise ValidationError(_(f"Quantity must not exceed available stock quantity ({q})")) + + return quantity + + serial_numbers = serializers.CharField( + label=_('Serial Numbers'), + help_text=_('Enter serial numbers for new items'), + allow_blank=False, + required=True, + ) + + destination = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Location'), + help_text=_('Destination stock location'), + ) + + notes = serializers.CharField( + required=False, + allow_blank=True, + label=_("Notes"), + help_text=_("Optional note field") + ) + + def validate(self, data): + """ + Check that the supplied serial numbers are valid + """ + + data = super().validate(data) + + item = self.context['item'] + + if not item.part.trackable: + raise ValidationError(_("Serial numbers cannot be assigned to this part")) + + # Ensure the serial numbers are valid! + quantity = data['quantity'] + serial_numbers = data['serial_numbers'] + + try: + serials = InvenTree.helpers.extract_serial_numbers(serial_numbers, quantity) + except DjangoValidationError as e: + raise ValidationError({ + 'serial_numbers': e.messages, + }) + + existing = item.part.find_conflicting_serial_numbers(serials) + + if len(existing) > 0: + exists = ','.join([str(x) for x in existing]) + error = _('Serial numbers already exist') + ": " + exists + + raise ValidationError({ + 'serial_numbers': error, + }) + + return data + + def save(self): + + item = self.context['item'] + request = self.context['request'] + user = request.user + + data = self.validated_data + + serials = InvenTree.helpers.extract_serial_numbers( + data['serial_numbers'], + data['quantity'], + ) + + item.serializeStock( + data['quantity'], + serials, + user, + notes=data.get('notes', ''), + location=data['destination'], + ) -class LocationSerializer(InvenTreeModelSerializer): +class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Detailed information about a stock location """ @@ -278,7 +393,7 @@ class LocationSerializer(InvenTreeModelSerializer): ] -class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): +class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): """ Serializer for StockItemAttachment model """ def __init__(self, *args, **kwargs): @@ -289,9 +404,9 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): if user_detail is not True: self.fields.pop('user_detail') - user_detail = UserSerializerBrief(source='user', read_only=True) + user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True) - attachment = InvenTreeAttachmentSerializerField(required=True) + attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True) # TODO: Record the uploading user when creating or updating an attachment! @@ -316,14 +431,14 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): ] -class StockItemTestResultSerializer(InvenTreeModelSerializer): +class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Serializer for the StockItemTestResult model """ - user_detail = UserSerializerBrief(source='user', read_only=True) + user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True) key = serializers.CharField(read_only=True) - attachment = InvenTreeAttachmentSerializerField(required=False) + attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False) def __init__(self, *args, **kwargs): user_detail = kwargs.pop('user_detail', False) @@ -357,7 +472,7 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): ] -class StockTrackingSerializer(InvenTreeModelSerializer): +class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Serializer for StockItemTracking model """ def __init__(self, *args, **kwargs): @@ -377,7 +492,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer): item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) - user_detail = UserSerializerBrief(source='user', many=False, read_only=True) + user_detail = InvenTree.serializers.UserSerializerBrief(source='user', many=False, read_only=True) deltas = serializers.JSONField(read_only=True) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index c2220776d7..2bd878549c 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -418,12 +418,18 @@ {{ block.super }} $("#stock-serialize").click(function() { - launchModalForm( - "{% url 'stock-item-serialize' item.id %}", - { - reload: true, + + serializeStockItem({{ item.pk }}, { + reload: true, + data: { + quantity: {{ item.quantity }}, + {% if item.location %} + destination: {{ item.location.pk }}, + {% elif item.part.default_location %} + destination: {{ item.part.default_location.pk }}, + {% endif %} } - ); + }); }); $('#stock-install-in').click(function() { diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 3aacf8a139..ce1ebb0afe 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -126,46 +126,6 @@ class StockItemTest(StockViewTestCase): self.assertIn(expected, str(response.content)) - def test_serialize_item(self): - # Test the serialization view - - url = reverse('stock-item-serialize', args=(100,)) - - # GET the form - response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data_valid = { - 'quantity': 5, - 'serial_numbers': '1-5', - 'destination': 4, - 'notes': 'Serializing stock test' - } - - data_invalid = { - 'quantity': 4, - 'serial_numbers': 'dd-23-adf', - 'destination': 'blorg' - } - - # POST - response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - self.assertTrue(data['form_valid']) - - # Try again to serialize with the same numbers - response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - self.assertFalse(data['form_valid']) - - # POST with invalid data - response = self.client.post(url, data_invalid, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - self.assertFalse(data['form_valid']) - class StockOwnershipTest(StockViewTestCase): """ Tests for stock ownership views """ diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 5441101aa1..7c35aebcaf 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -20,7 +20,6 @@ location_urls = [ stock_item_detail_urls = [ url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), - url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'), url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 44c1a824bd..ba45314dcb 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -1027,89 +1027,6 @@ class StockLocationCreate(AjaxCreateView): pass -class StockItemSerialize(AjaxUpdateView): - """ View for manually serializing a StockItem """ - - model = StockItem - ajax_template_name = 'stock/item_serialize.html' - ajax_form_title = _('Serialize Stock') - form_class = StockForms.SerializeStockForm - - def get_form(self): - - context = self.get_form_kwargs() - - # Pass the StockItem object through to the form - context['item'] = self.get_object() - - form = StockForms.SerializeStockForm(**context) - - return form - - def get_initial(self): - - initials = super().get_initial().copy() - - item = self.get_object() - - initials['quantity'] = item.quantity - initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity) - if item.location is not None: - initials['destination'] = item.location.pk - - return initials - - def get(self, request, *args, **kwargs): - - return super().get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - - form = self.get_form() - - item = self.get_object() - - quantity = request.POST.get('quantity', 0) - serials = request.POST.get('serial_numbers', '') - dest_id = request.POST.get('destination', None) - notes = request.POST.get('note', '') - user = request.user - - valid = True - - try: - destination = StockLocation.objects.get(pk=dest_id) - except (ValueError, StockLocation.DoesNotExist): - destination = None - - try: - numbers = extract_serial_numbers(serials, quantity) - except ValidationError as e: - form.add_error('serial_numbers', e.messages) - valid = False - numbers = [] - - if valid: - try: - item.serializeStock(quantity, numbers, user, notes=notes, location=destination) - except ValidationError as e: - messages = e.message_dict - - for k in messages.keys(): - if k in ['quantity', 'destination', 'serial_numbers']: - form.add_error(k, messages[k]) - else: - form.add_error(None, messages[k]) - - valid = False - - data = { - 'form_valid': valid, - } - - return self.renderJsonResponse(request, form, data=data) - - class StockItemCreate(AjaxCreateView): """ View for creating a new StockItem diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index d4a250c332..75f5e133d0 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -52,12 +52,39 @@ loadStockTrackingTable, loadTableFilters, removeStockRow, + serializeStockItem, stockItemFields, stockLocationFields, stockStatusCodes, */ +/* + * Launches a modal form to serialize a particular StockItem + */ + +function serializeStockItem(pk, options={}) { + + var url = `/api/stock/${pk}/serialize/`; + + options.method = 'POST'; + options.title = '{% trans "Serialize Stock Item" %}'; + + options.fields = { + quantity: {}, + serial_numbers: { + icon: 'fa-hashtag', + }, + destination: { + icon: 'fa-sitemap', + }, + notes: {}, + } + + constructForm(url, options); +} + + function stockLocationFields(options={}) { var fields = { parent: { From 1d42d33c8e4cbfffd20be485bd821af74f6cc987 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 11:33:44 +1100 Subject: [PATCH 13/20] style fixes --- InvenTree/stock/serializers.py | 5 ++++- InvenTree/stock/test_views.py | 23 ++++++++--------------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 8513fa8740..850ebcea3b 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -277,8 +277,11 @@ class SerializeStockItemSerializer(serializers.Serializer): ) def validate_quantity(self, quantity): + """ + Validate that the quantity value is correct + """ - item = self.context['item'] + item = self.context['item'] if quantity < 0: raise ValidationError(_("Quantity must be greater than zero")) diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index ce1ebb0afe..e210a6ac95 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -7,11 +7,8 @@ from django.contrib.auth.models import Group from common.models import InvenTreeSetting -import json from datetime import datetime, timedelta -from InvenTree.status_codes import StockStatus - class StockViewTestCase(TestCase): @@ -169,34 +166,31 @@ class StockOwnershipTest(StockViewTestCase): InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user) self.assertEqual(True, InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')) + """ + TODO: Refactor this following test to use the new API form def test_owner_control(self): # Test stock location and item ownership - from .models import StockLocation, StockItem + from .models import StockLocation from users.models import Owner - user_group = self.user.groups.all()[0] - user_group_owner = Owner.get_owner(user_group) new_user_group = self.new_user.groups.all()[0] new_user_group_owner = Owner.get_owner(new_user_group) user_as_owner = Owner.get_owner(self.user) new_user_as_owner = Owner.get_owner(self.new_user) - test_location_id = 4 - test_item_id = 11 - # Enable ownership control self.enable_ownership() - """ - TODO: Refactor this following test to use the new API form + test_location_id = 4 + test_item_id = 11 # Set ownership on existing item (and change location) response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), {'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertContains(response, '"form_valid": true', status_code=200) - """ + # Logout self.client.logout() @@ -204,8 +198,7 @@ class StockOwnershipTest(StockViewTestCase): # Login with new user self.client.login(username='john', password='custom123') - """ - TODO: Refactor this following test to use the new API form + # TODO: Refactor this following test to use the new API form # Test item edit response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), {'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk}, @@ -214,7 +207,6 @@ class StockOwnershipTest(StockViewTestCase): # Make sure the item's owner is unchanged item = StockItem.objects.get(pk=test_item_id) self.assertEqual(item.owner, user_as_owner) - """ # Create new parent location parent_location = { @@ -253,3 +245,4 @@ class StockOwnershipTest(StockViewTestCase): # Logout self.client.logout() + """ From 97326d9fb2448e6258068546226f25288676acba Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 22:45:11 +1100 Subject: [PATCH 14/20] Display stock item owner (if applicable) --- InvenTree/stock/templates/stock/item_base.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index ea5484a73a..f64c9b0704 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -410,8 +410,15 @@ {{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }} {% endif %} + {% if item.owner %} + + + {% trans "Owner" %} + {{ item.owner }} + + {% endif %} -{% endblock %} +{% endblock details_right %} {% block js_ready %} From 3be4acf3ef9d8290ab8472ab50a743b17968ab29 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 23:09:49 +1100 Subject: [PATCH 15/20] More refactoring for notifications - Adds default behaviour for successful stock item creation --- .../static/script/inventree/notification.js | 19 ++++++++----- InvenTree/stock/templates/stock/location.html | 4 +-- InvenTree/templates/account/base.html | 8 +++++- InvenTree/templates/js/translated/barcode.js | 13 ++++++--- InvenTree/templates/js/translated/forms.js | 8 +++--- InvenTree/templates/js/translated/modals.js | 8 +++--- InvenTree/templates/js/translated/stock.js | 27 +++++++++++++++++++ 7 files changed, 66 insertions(+), 21 deletions(-) diff --git a/InvenTree/InvenTree/static/script/inventree/notification.js b/InvenTree/InvenTree/static/script/inventree/notification.js index 399ba1d359..f6bdf3bc57 100644 --- a/InvenTree/InvenTree/static/script/inventree/notification.js +++ b/InvenTree/InvenTree/static/script/inventree/notification.js @@ -1,7 +1,7 @@ /* * Add a cached alert message to sesion storage */ -function addCachedAlert(message, style) { +function addCachedAlert(message, options={}) { var alerts = sessionStorage.getItem('inventree-alerts'); @@ -13,7 +13,8 @@ function addCachedAlert(message, style) { alerts.push({ message: message, - style: style + style: options.style || 'success', + icon: options.icon, }); sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts)); @@ -31,13 +32,13 @@ function clearCachedAlerts() { /* * Display an alert, or cache to display on reload */ -function showAlertOrCache(message, style, cache=false) { +function showAlertOrCache(message, cache, options={}) { if (cache) { - addCachedAlert(message, style); + addCachedAlert(message, options); } else { - showMessage(message, {style: style}); + showMessage(message, options); } } @@ -50,7 +51,13 @@ function showCachedAlerts() { var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || []; alerts.forEach(function(alert) { - showMessage(alert.message, {style: alert.style}); + showMessage( + alert.message, + { + style: alert.style || 'success', + icon: alert.icon, + } + ); }); clearCachedAlerts(); diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 8270db22f5..24a6da69a9 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -308,12 +308,12 @@ $('#item-create').click(function () { createNewStockItem({ - follow: true, + table: '#stock-table', data: { {% if location %} location: {{ location.id }} {% endif %} - } + }, }); }); diff --git a/InvenTree/templates/account/base.html b/InvenTree/templates/account/base.html index fa2a34f79d..7f2486bfcc 100644 --- a/InvenTree/templates/account/base.html +++ b/InvenTree/templates/account/base.html @@ -111,7 +111,13 @@ $(document).ready(function () { // notifications {% if messages %} {% for message in messages %} - showAlertOrCache('{{ message }}', 'info', true); + showAlertOrCache( + '{{ message }}', + true, + { + style: 'info', + } + ); {% endfor %} {% endif %} diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index 4b61249d0b..fcc4df5f50 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -480,10 +480,13 @@ function barcodeCheckIn(location_id) { $(modal).modal('hide'); if (status == 'success' && 'success' in response) { - showAlertOrCache(response.success, 'success', true); + addCachedAlert(response.success); location.reload(); } else { - showAlertOrCache('{% trans "Error transferring stock" %}', 'danger', false); + showMessage('{% trans "Error transferring stock" %}', { + style: 'danger', + icon: 'fas fa-times-circle', + }); } } } @@ -604,10 +607,12 @@ function scanItemsIntoLocation(item_id_list, options={}) { $(modal).modal('hide'); if (status == 'success' && 'success' in response) { - showAlertOrCache(response.success, 'success', true); + addCachedAlert(response.success); location.reload(); } else { - showAlertOrCache('{% trans "Error transferring stock" %}', 'danger', false); + showMessage('{% trans "Error transferring stock" %}', { + style: 'danger', + }); } } } diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 2308c1e247..0da32c58ff 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -904,19 +904,19 @@ function handleFormSuccess(response, options) { // Display any messages if (response && response.success) { - showAlertOrCache(response.success, 'success', cache); + showAlertOrCache(response.success, cache, {style: 'success'}); } if (response && response.info) { - showAlertOrCache(response.info, 'info', cache); + showAlertOrCache(response.info, cache, {style: 'info'}); } if (response && response.warning) { - showAlertOrCache(response.warning, 'warning', cache); + showAlertOrCache(response.warning, cache, {style: 'warning'}); } if (response && response.danger) { - showAlertOrCache(response.danger, 'dagner', cache); + showAlertOrCache(response.danger, cache, {style: 'danger'}); } if (options.onSuccess) { diff --git a/InvenTree/templates/js/translated/modals.js b/InvenTree/templates/js/translated/modals.js index aefdba604f..4cd0be8cec 100644 --- a/InvenTree/templates/js/translated/modals.js +++ b/InvenTree/templates/js/translated/modals.js @@ -399,19 +399,19 @@ function afterForm(response, options) { // Display any messages if (response.success) { - showAlertOrCache(response.success, 'success', cache); + showAlertOrCache(response.success, cache, {style: 'success'}); } if (response.info) { - showAlertOrCache(response.info, 'info', cache); + showAlertOrCache(response.info, cache, {style: 'info'}); } if (response.warning) { - showAlertOrCache(response.warning, 'warning', cache); + showAlertOrCache(response.warning, cache, {style: 'warning'}); } if (response.danger) { - showAlertOrCache(response.danger, 'danger', cache); + showAlertOrCache(response.danger, cache, {style: 'danger'}); } // Was a callback provided? diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 7ba5a52b97..3e6b35ca83 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -317,6 +317,33 @@ function createNewStockItem(options={}) { options.fields = stockItemFields(options); options.groups = stockItemGroups(options); + if (!options.onSuccess) { + options.onSuccess = function(response) { + // If a single stock item has been created, follow it! + if (response.pk) { + var url = `/stock/item/${pk}/`; + + addCachedAlert('{% trans "Created stock item" %}', { + icon: 'fas fa-boxes', + }); + + location.href = url; + } else { + + var q = response.quantity; + + showMessage('{% trans "Created stock items" %}', { + icon: 'fas fa-boxes', + }); + + if (options.table) { + // Reload the table + $(options.table).bootstrapTable('refresh'); + } + } + } + } + constructForm(url, options); } From b41dbba2b0205239b7c2e6d7bf2193615f4f2cd3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 23:18:59 +1100 Subject: [PATCH 16/20] Correctly handle serialization of newly created stock --- InvenTree/stock/api.py | 21 ++++++++++++++++++++- InvenTree/templates/js/translated/forms.js | 6 ++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 5ca552dd8e..9d2860e41d 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -445,8 +445,14 @@ class StockList(generics.ListCreateAPIView): # Finally, save the item (with user information) item.save(user=user) - # Serialize the stock, if required if serials: + """ + Serialize the stock, if required + + - Note that the "original" stock item needs to be created first, so it can be serialized + - It is then immediately deleted + """ + try: item.serializeStock( quantity, @@ -455,6 +461,19 @@ class StockList(generics.ListCreateAPIView): notes=notes, location=item.location, ) + + headers = self.get_success_headers(serializer.data) + + # Delete the original item + item.delete() + + response_data = { + 'quantity': quantity, + 'serial_numbers': serials, + } + + return Response(response_data, status=status.HTTP_201_CREATED, headers=headers) + except DjangoValidationError as e: raise ValidationError({ 'quantity': e.messages, diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 0da32c58ff..42e3aac289 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -115,6 +115,10 @@ function canDelete(OPTIONS) { */ function getApiEndpointOptions(url, callback) { + if (!url) { + return; + } + // Return the ajax request object $.ajax({ url: url, @@ -727,6 +731,8 @@ function submitFormData(fields, options) { break; default: $(options.modal).modal('hide'); + + console.log(`upload error at ${options.url}`); showApiError(xhr, options.url); break; } From f27acde9346dfb16f4013ded8bfcd92449c1030c Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Nov 2021 00:02:55 +1100 Subject: [PATCH 17/20] More fixes - Allow stock item creation for inactive parts - Better handling of successful stock item creation - Disable fields rather than hiding them --- .../templates/company/supplier_part.html | 1 - InvenTree/part/templates/part/detail.html | 2 - .../migrations/0067_alter_stockitem_part.py | 20 ++++++++ InvenTree/stock/models.py | 1 - InvenTree/stock/templates/stock/location.html | 1 - InvenTree/templates/js/translated/forms.js | 20 ++++++++ InvenTree/templates/js/translated/stock.js | 50 ++++++++++++------- 7 files changed, 73 insertions(+), 22 deletions(-) create mode 100644 InvenTree/stock/migrations/0067_alter_stockitem_part.py diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index d3bee4e797..276a9f7ebc 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -322,7 +322,6 @@ $("#item-create").click(function() { part: {{ part.part.id }}, supplier_part: {{ part.id }}, }, - reload: true, }); }); diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index e9b7b4252a..f03127e996 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -886,7 +886,6 @@ onPanelLoad("part-stock", function() { $('#new-stock-item').click(function () { createNewStockItem({ - reload: true, data: { part: {{ part.id }}, {% if part.default_location %} @@ -919,7 +918,6 @@ $('#item-create').click(function () { createNewStockItem({ - reload: true, data: { part: {{ part.id }}, } diff --git a/InvenTree/stock/migrations/0067_alter_stockitem_part.py b/InvenTree/stock/migrations/0067_alter_stockitem_part.py new file mode 100644 index 0000000000..7f00b8f7b1 --- /dev/null +++ b/InvenTree/stock/migrations/0067_alter_stockitem_part.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.5 on 2021-11-04 12:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0074_partcategorystar'), + ('stock', '0066_stockitem_scheduled_for_deletion'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='part', + field=models.ForeignKey(help_text='Base part', limit_choices_to={'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.part', verbose_name='Base Part'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index eb0e6aa12f..320807e0c1 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -456,7 +456,6 @@ class StockItem(MPTTModel): verbose_name=_('Base Part'), related_name='stock_items', help_text=_('Base part'), limit_choices_to={ - 'active': True, 'virtual': False }) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 24a6da69a9..18b78b2290 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -308,7 +308,6 @@ $('#item-create').click(function () { createNewStockItem({ - table: '#stock-table', data: { {% if location %} location: {{ location.id }} diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 42e3aac289..a86b64d0e2 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -25,6 +25,9 @@ */ /* exported + clearFormInput, + disableFormInput, + enableFormInput, hideFormInput, setFormGroupVisibility, showFormInput, @@ -1261,6 +1264,23 @@ function initializeGroups(fields, options) { } } +// Clear a form input +function clearFormInput(name, options) { + updateFieldValue(name, null, {}, options); +} + +// Disable a form input +function disableFormInput(name, options) { + $(options.modal).find(`#id_${name}`).prop('disabled', true); +} + + +// Enable a form input +function enableFormInput(name, options) { + $(options.modal).find(`#id_${name}`).prop('disabled', false); +} + + // Hide a form input function hideFormInput(name, options) { $(options.modal).find(`#div_id_${name}`).hide(); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 3e6b35ca83..a7a60230f4 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -138,20 +138,33 @@ function stockItemFields(options={}) { onSelect: function(data, field, opts) { // Callback when a new "part" is selected - // If we are "creating" a new stock item + // If we are "creating" a new stock item, + // change the available fields based on the part properties if (options.create) { + // If a "trackable" part is selected, enable serial number field if (data.trackable) { - showFormInput('serial_numbers', opts); + enableFormInput('serial_numbers', opts); + // showFormInput('serial_numbers', opts); } else { - updateFieldValue('serial_numbers', '', {}, opts); - hideFormInput('serial_numbers', opts); + clearFormInput('serial_numbers', opts); + disableFormInput('serial_numbers', opts); + } + + // Enable / disable fields based on purchaseable status + if (data.purchaseable) { + enableFormInput('supplier_part', opts); + enableFormInput('purchase_price', opts); + enableFormInput('purchase_price_currency', opts); + } else { + clearFormInput('supplier_part', opts); + clearFormInput('purchase_price', opts); + + disableFormInput('supplier_part', opts); + disableFormInput('purchase_price', opts); + disableFormInput('purchase_price_currency', opts); } } - - // TODO: Hide "purchase price" fields for non purchaseable parts! - - // TODO: Update "location" based on "default_location" returned } }, supplier_part: { @@ -204,7 +217,7 @@ function stockItemFields(options={}) { }; if (options.create) { - // Use "serial numbers" field when creating a new stock item + // Use special "serial numbers" field when creating a new stock item delete fields['serial']; } else { // These fields cannot be edited once the stock item has been created @@ -321,25 +334,28 @@ function createNewStockItem(options={}) { options.onSuccess = function(response) { // If a single stock item has been created, follow it! if (response.pk) { - var url = `/stock/item/${pk}/`; + var url = `/stock/item/${response.pk}/`; - addCachedAlert('{% trans "Created stock item" %}', { + addCachedAlert('{% trans "Created new stock item" %}', { icon: 'fas fa-boxes', }); - location.href = url; + window.location.href = url; } else { + // Multiple stock items have been created (i.e. serialized stock) + var q = response.quantity; - showMessage('{% trans "Created stock items" %}', { + showMessage('{% trans "Created multiple stock items" %}', { icon: 'fas fa-boxes', + details: `{% trans "Serial numbers" %}: ${response.serial_numbers}` }); - if (options.table) { - // Reload the table - $(options.table).bootstrapTable('refresh'); - } + var table = options.table || '#stock-table'; + + // Reload the table + $(table).bootstrapTable('refresh'); } } } From 78ac40083a8193221749108f6d401386b49a1c17 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Nov 2021 00:27:26 +1100 Subject: [PATCH 18/20] Fixes for stock api unit tests - Remove old unit tests - Require quantity when creating a new stock item --- InvenTree/stock/api.py | 39 +++++++++++---------- InvenTree/stock/test_api.py | 12 +++---- InvenTree/stock/test_views.py | 64 ----------------------------------- InvenTree/stock/urls.py | 2 -- 4 files changed, 27 insertions(+), 90 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 9d2860e41d..e287441382 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -12,39 +12,39 @@ from django.conf.urls import url, include from django.http import JsonResponse from django.db.models import Q from django.db import transaction +from django.utils.translation import ugettext_lazy as _ + +from django_filters.rest_framework import DjangoFilterBackend +from django_filters import rest_framework as rest_filters from rest_framework import status from rest_framework.serializers import ValidationError from rest_framework.response import Response from rest_framework import generics, filters -from django_filters.rest_framework import DjangoFilterBackend -from django_filters import rest_framework as rest_filters - -from .models import StockLocation, StockItem -from .models import StockItemTracking -from .models import StockItemAttachment -from .models import StockItemTestResult - -from part.models import BomItem, Part, PartCategory -from part.serializers import PartBriefSerializer +import common.settings +import common.models from company.models import Company, SupplierPart from company.serializers import CompanySerializer, SupplierPartSerializer +from InvenTree.helpers import str2bool, isNull, extract_serial_numbers +from InvenTree.api import AttachmentMixin +from InvenTree.filters import InvenTreeOrderingFilter + from order.models import PurchaseOrder from order.models import SalesOrder, SalesOrderAllocation from order.serializers import POSerializer -import common.settings -import common.models +from part.models import BomItem, Part, PartCategory +from part.serializers import PartBriefSerializer +from stock.models import StockLocation, StockItem +from stock.models import StockItemTracking +from stock.models import StockItemAttachment +from stock.models import StockItemTestResult import stock.serializers as StockSerializers -from InvenTree.helpers import str2bool, isNull, extract_serial_numbers -from InvenTree.api import AttachmentMixin -from InvenTree.filters import InvenTreeOrderingFilter - class StockDetail(generics.RetrieveUpdateDestroyAPIView): """ API detail endpoint for Stock object @@ -411,7 +411,12 @@ class StockList(generics.ListCreateAPIView): # Check if a set of serial numbers was provided serial_numbers = data.get('serial_numbers', '') - quantity = data['quantity'] + quantity = data.get('quantity', None) + + if quantity is None: + raise ValidationError({ + 'quantity': _('Quantity is required'), + }) notes = data.get('notes', '') diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index d07c35aaf7..422f9f11ab 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -364,24 +364,22 @@ class StockItemTest(StockAPITestCase): 'part': 1, 'location': 1, }, - expected_code=201, + expected_code=400 ) - # Item should have been created with default quantity - self.assertEqual(response.data['quantity'], 1) + self.assertIn('Quantity is required', str(response.data)) # POST with quantity and part and location - response = self.client.post( + response = self.post( self.list_url, data={ 'part': 1, 'location': 1, 'quantity': 10, - } + }, + expected_code=201 ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - def test_default_expiry(self): """ Test that the "default_expiry" functionality works via the API. diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index e210a6ac95..d3019ee541 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -60,70 +60,6 @@ class StockListTest(StockViewTestCase): self.assertEqual(response.status_code, 200) -class StockItemTest(StockViewTestCase): - """" Tests for StockItem views """ - - def test_qr_code(self): - # QR code for a valid item - response = self.client.get(reverse('stock-item-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # QR code for an invalid item - response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_create_item(self): - """ - Test creation of StockItem - """ - - url = reverse('stock-item-create') - - response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - response = self.client.get(url, {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Copy from a valid item, valid location - response = self.client.get(url, {'location': 1, 'copy': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Copy from an invalid item, invalid location - response = self.client.get(url, {'location': 999, 'copy': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_create_stock_with_expiry(self): - """ - Test creation of stock item of a part with an expiry date. - The initial value for the "expiry_date" field should be pre-filled, - and should be in the future! - """ - - # First, ensure that the expiry date feature is enabled! - InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user) - - url = reverse('stock-item-create') - - response = self.client.get(url, {'part': 25}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - self.assertEqual(response.status_code, 200) - - # We are expecting 10 days in the future - expiry = datetime.now().date() + timedelta(10) - - expected = f'name=\\\\"expiry_date\\\\" value=\\\\"{expiry.isoformat()}\\\\"' - - self.assertIn(expected, str(response.content)) - - # Now check with a part which does *not* have a default expiry period - response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - expected = 'name=\\\\"expiry_date\\\\" placeholder=\\\\"\\\\"' - - self.assertIn(expected, str(response.content)) - - class StockOwnershipTest(StockViewTestCase): """ Tests for stock ownership views """ diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 7c35aebcaf..b28104f388 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -45,8 +45,6 @@ stock_urls = [ # Stock location url(r'^location/', include(location_urls)), - url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'), - url(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'), url(r'^track/', include(stock_tracking_urls)), From 5a0ff4c0778dcb455caed7b744efd9bfb35fb34f Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Nov 2021 00:32:11 +1100 Subject: [PATCH 19/20] JS linting --- InvenTree/templates/js/translated/barcode.js | 1 - InvenTree/templates/js/translated/stock.js | 21 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index fcc4df5f50..2778983341 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -10,7 +10,6 @@ modalSetSubmitText, modalShowSubmitButton, modalSubmit, - showAlertOrCache, showQuestionDialog, */ diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index a7a60230f4..1e6c473841 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -4,12 +4,11 @@ /* globals attachSelect, - enableField, - clearField, - clearFieldOptions, closeModal, constructField, constructFormBody, + disableField, + enableField, getFormFieldValue, global_settings, handleFormErrors, @@ -33,10 +32,8 @@ printStockItemLabels, printTestReports, renderLink, - reloadFieldOptions, scanItemsIntoLocation, showAlertDialog, - setFieldValue, setupFilterList, showApiError, stockStatusDisplay, @@ -44,6 +41,10 @@ /* exported createNewStockItem, + createStockLocation, + duplicateStockItem, + editStockItem, + editStockLocation, exportStock, loadInstalledInTable, loadStockLocationTable, @@ -344,12 +345,14 @@ function createNewStockItem(options={}) { } else { // Multiple stock items have been created (i.e. serialized stock) - - var q = response.quantity; + var details = ` +
    {% trans "Quantity" %}: ${response.quantity} +
    {% trans "Serial Numbers" %}: ${response.serial_numbers} + `; showMessage('{% trans "Created multiple stock items" %}', { icon: 'fas fa-boxes', - details: `{% trans "Serial numbers" %}: ${response.serial_numbers}` + details: details, }); var table = options.table || '#stock-table'; @@ -357,7 +360,7 @@ function createNewStockItem(options={}) { // Reload the table $(table).bootstrapTable('refresh'); } - } + }; } constructForm(url, options); From 185924e1f847d0444a3ebadb42f65e2fd68654c8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Nov 2021 00:35:47 +1100 Subject: [PATCH 20/20] More linting --- InvenTree/stock/test_views.py | 2 -- InvenTree/templates/js/translated/stock.js | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index d3019ee541..36042b9bc2 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -7,8 +7,6 @@ from django.contrib.auth.models import Group from common.models import InvenTreeSetting -from datetime import datetime, timedelta - class StockViewTestCase(TestCase): diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 1e6c473841..ec785969cd 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -7,8 +7,6 @@ closeModal, constructField, constructFormBody, - disableField, - enableField, getFormFieldValue, global_settings, handleFormErrors, @@ -80,7 +78,7 @@ function serializeStockItem(pk, options={}) { icon: 'fa-sitemap', }, notes: {}, - } + }; constructForm(url, options); }