From 4b3f77763d26257cca31e6403d1640798754aa0a Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 3 Jun 2022 08:36:08 +1000 Subject: [PATCH] Return from customer (#3120) * Adds ability to return item into stock via the API * Remove old server-side form / view for returning stock from a customer * Add unit tests for new API endpoint --- InvenTree/InvenTree/api_version.py | 5 ++- InvenTree/stock/api.py | 14 +++++-- InvenTree/stock/forms.py | 15 ------- InvenTree/stock/models.py | 3 +- InvenTree/stock/serializers.py | 42 +++++++++++++++++++ .../stock/templates/stock/item_base.html | 20 ++++++--- InvenTree/stock/test_api.py | 39 +++++++++++++++++ InvenTree/stock/urls.py | 1 - InvenTree/stock/views.py | 29 ------------- 9 files changed, 113 insertions(+), 55 deletions(-) diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 49968047cd..bca97ec920 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 54 +INVENTREE_API_VERSION = 55 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v55 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3120 + - Converts the 'StockItemReturn' functionality to make use of the API + v54 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3117 - Adds 'available_stock' annotation on the SalesOrderLineItem API - Adds (well, fixes) 'overdue' annotation on the SalesOrderLineItem API diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 536ac66a4e..cc427953a8 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -106,7 +106,7 @@ class StockItemContextMixin: class StockItemSerialize(StockItemContextMixin, generics.CreateAPIView): """API endpoint for serializing a stock item.""" - queryset = StockItem.objects.none() + queryset = StockItem.objects.all() serializer_class = StockSerializers.SerializeStockItemSerializer @@ -118,17 +118,24 @@ class StockItemInstall(StockItemContextMixin, generics.CreateAPIView): - stock_item must be serialized (and not belong to another item) """ - queryset = StockItem.objects.none() + queryset = StockItem.objects.all() serializer_class = StockSerializers.InstallStockItemSerializer class StockItemUninstall(StockItemContextMixin, generics.CreateAPIView): """API endpoint for removing (uninstalling) items from this item.""" - queryset = StockItem.objects.none() + queryset = StockItem.objects.all() serializer_class = StockSerializers.UninstallStockItemSerializer +class StockItemReturn(StockItemContextMixin, generics.CreateAPIView): + """API endpoint for returning a stock item from a customer""" + + queryset = StockItem.objects.all() + serializer_class = StockSerializers.ReturnStockItemSerializer + + class StockAdjustView(generics.CreateAPIView): """A generic class for handling stocktake actions. @@ -1370,6 +1377,7 @@ stock_api_urls = [ re_path(r'^(?P\d+)/', include([ re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'), re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'), + re_path(r'^return/', StockItemReturn.as_view(), name='api-stock-item-return'), re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'), re_path(r'^uninstall/', StockItemUninstall.as_view(), name='api-stock-item-uninstall'), re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'), diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 5eaf9d917a..948d963cd5 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -5,21 +5,6 @@ from InvenTree.forms import HelperForm from .models import StockItem, StockItemTracking -class ReturnStockItemForm(HelperForm): - """Form for manually returning a StockItem into stock. - - TODO: This could be a simple API driven form! - """ - - class Meta: - """Metaclass options.""" - - model = StockItem - fields = [ - 'location', - ] - - class ConvertStockItemForm(HelperForm): """Form for converting a StockItem to a variant of its current part. diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 292afae53d..c3801dee42 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -894,7 +894,8 @@ class StockItem(MetadataMixin, MPTTModel): # Return the reference to the stock item return item - def returnFromCustomer(self, location, user=None, **kwargs): + @transaction.atomic + def return_from_customer(self, location, user=None, **kwargs): """Return stock item from customer, back into the specified location.""" notes = kwargs.get('notes', '') diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index fe4957d5b1..96dfb0b839 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -464,6 +464,48 @@ class UninstallStockItemSerializer(serializers.Serializer): ) +class ReturnStockItemSerializer(serializers.Serializer): + """DRF serializer for returning a stock item from a customer""" + + class Meta: + """Metaclass options""" + + fields = [ + 'location', + 'note', + ] + + location = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, required=True, allow_null=False, + label=_('Location'), + help_text=_('Destination location for returned item'), + ) + + notes = serializers.CharField( + label=_('Notes'), + help_text=_('Add transaction note (optional)'), + required=False, allow_blank=True, + ) + + def save(self): + """Save the serialzier to return the item into stock""" + + item = self.context['item'] + request = self.context['request'] + + data = self.validated_data + + location = data['location'] + notes = data.get('notes', '') + + item.return_from_customer( + location, + user=request.user, + notes=notes + ) + + class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for a simple tree view.""" diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 9ae43c1c4b..c239ec0d09 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -632,11 +632,21 @@ $('#stock-remove').click(function() { {% else %} $("#stock-return-from-customer").click(function() { - launchModalForm("{% url 'stock-item-return' item.id %}", - { - reload: true, - } - ); + + constructForm('{% url "api-stock-item-return" item.pk %}', { + fields: { + location: { + {% if item.part.default_location %} + value: {{ item.part.default_location.pk }}, + {% endif %} + }, + notes: {}, + }, + method: 'POST', + title: '{% trans "Return to Stock" %}', + reload: true, + }); + }); {% endif %} diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 7a7c5aa67f..4de9d47031 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -663,6 +663,45 @@ class StockItemTest(StockAPITestCase): self.assertIsNone(sub_item.belongs_to) self.assertEqual(sub_item.location.pk, 1) + def test_return_from_customer(self): + """Test that we can return a StockItem from a customer, via the API""" + + # Assign item to customer + item = StockItem.objects.get(pk=521) + customer = company.models.Company.objects.get(pk=4) + + item.customer = customer + item.save() + + n_entries = item.tracking_info_count + + url = reverse('api-stock-item-return', kwargs={'pk': item.pk}) + + # Empty POST will fail + response = self.post( + url, {}, + expected_code=400 + ) + + self.assertIn('This field is required', str(response.data['location'])) + + response = self.post( + url, + { + 'location': '1', + 'notes': 'Returned from this customer for testing', + }, + expected_code=201, + ) + + item.refresh_from_db() + + # A new stock tracking entry should have been created + self.assertEqual(n_entries + 1, item.tracking_info_count) + + # The item is now in stock + self.assertIsNone(item.customer) + class StocktakeTest(StockAPITestCase): """Series of tests for the Stocktake API.""" diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 859a9114a4..7fa8f04626 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -21,7 +21,6 @@ stock_item_detail_urls = [ re_path(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), re_path(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), re_path(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), - re_path(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'), re_path(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 1d49b13f13..b4d1e0047a 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -125,35 +125,6 @@ class StockLocationQRCode(QRCodeView): return None -class StockItemReturnToStock(AjaxUpdateView): - """View for returning a stock item (which is assigned to a customer) to stock.""" - - model = StockItem - ajax_form_title = _("Return to Stock") - context_object_name = "item" - form_class = StockForms.ReturnStockItemForm - - def validate(self, item, form, **kwargs): - """Make sure required data is there.""" - location = form.cleaned_data.get('location', None) - - if not location: - form.add_error('location', _('Specify a valid location')) - - def save(self, item, form, **kwargs): - """Return stock.""" - location = form.cleaned_data.get('location', None) - - if location: - item.returnFromCustomer(location, self.request.user) - - def get_data(self): - """Set success message.""" - return { - 'success': _('Stock item returned from customer') - } - - class StockItemDeleteTestData(AjaxUpdateView): """View for deleting all test data."""