diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index a1e2e3e768..b387e823bd 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -14,7 +14,7 @@ from .models import StockItemTracking from part.models import Part, PartCategory -from .serializers import StockItemSerializer, StockQuantitySerializer +from .serializers import StockItemSerializer from .serializers import LocationSerializer from .serializers import StockTrackingSerializer @@ -23,11 +23,12 @@ from InvenTree.helpers import str2bool, isNull from InvenTree.status_codes import StockStatus import os +from decimal import Decimal, InvalidOperation from rest_framework.serializers import ValidationError from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import generics, response, filters, permissions +from rest_framework import generics, filters, permissions class StockCategoryTree(TreeSerializer): @@ -95,60 +96,74 @@ class StockFilter(FilterSet): fields = ['quantity', 'part', 'location'] -class StockStocktake(APIView): - """ Stocktake API endpoint provides stock update of multiple items simultaneously. - The 'action' field tells the type of stock action to perform: - - count: Count the stock item(s) - - remove: Remove the quantity provided from stock - - add: Add the quantity provided from stock +class StockAdjust(APIView): + """ + A generic class for handling stocktake actions. + + Subclasses exist for: + + - StockCount: count stock items + - StockAdd: add stock items + - StockRemove: remove stock items + - StockTransfer: transfer stock items """ permission_classes = [ permissions.IsAuthenticated, ] + def get_items(self, request): + """ + Return a list of items posted to the endpoint. + Will raise validation errors if the items are not + correctly formatted. + """ + + _items = [] + + if 'item' in request.data: + _items = [request.data['item']] + elif 'items' in request.data: + _items = request.data['items'] + else: + raise ValidationError({'items': 'Request must contain list of stock items'}) + + # List of validated items + self.items = [] + + for entry in _items: + + try: + item = StockItem.objects.get(pk=entry.get('pk', None)) + except (ValueError, StockItem.DoesNotExist): + raise ValidationError({'pk': 'Each entry must contain a valid pk field'}) + + try: + quantity = Decimal(str(entry.get('quantity', None))) + except (ValueError, TypeError, InvalidOperation): + raise ValidationError({'quantity': 'Each entry must contain a valid quantity field'}) + + if quantity <= 0: + raise ValidationError({'quantity': 'Quantity field must be greater than zero'}) + + self.items.append({ + 'item': item, + 'quantity': quantity + }) + + self.notes = str(request.POST.get('notes', '')) + + +class StockCount(StockAdjust): + """ + Endpoint for counting stock (performing a stocktake). + """ + def post(self, request, *args, **kwargs): - if 'action' not in request.data: - raise ValidationError({'action': 'Stocktake action must be provided'}) - - action = request.data['action'] - - ACTIONS = ['count', 'remove', 'add'] - - if action not in ACTIONS: - raise ValidationError({'action': 'Action must be one of ' + ','.join(ACTIONS)}) - - elif 'items[]' not in request.data: - raise ValidationError({'items[]:' 'Request must contain list of items'}) - - items = [] - - # Ensure each entry is valid - for entry in request.data['items[]']: - if 'pk' not in entry: - raise ValidationError({'pk': 'Each entry must contain pk field'}) - elif 'quantity' not in entry: - raise ValidationError({'quantity': 'Each entry must contain quantity field'}) - - item = {} - try: - item['item'] = StockItem.objects.get(pk=entry['pk']) - except StockItem.DoesNotExist: - raise ValidationError({'pk': 'No matching StockItem found for pk={pk}'.format(pk=entry['pk'])}) - try: - item['quantity'] = int(entry['quantity']) - except ValueError: - raise ValidationError({'quantity': 'Quantity must be an integer'}) - - if item['quantity'] < 0: - raise ValidationError({'quantity': 'Quantity must be >= 0'}) - - items.append(item) - - # Stocktake notes - notes = '' + self.get_items(request) + """ if 'notes' in request.data: notes = request.data['notes'] @@ -166,10 +181,41 @@ class StockStocktake(APIView): elif action == u'add': if item['item'].add_stock(quantity, request.user, notes=notes): n += 1 + """ + + n = 0 return Response({'success': 'Updated stock for {n} items'.format(n=n)}) +class StockAdd(StockAdjust): + """ + Endpoint for adding stock + """ + + def post(self, request, *args, **kwargs): + + self.get_items(request) + + n = 0 + + return Response({"success": "Added stock for {n} items".format(n=n)}) + + +class StockRemove(StockAdjust): + """ + Endpoint for removing stock. + """ + + def post(self, request, *args, **kwargs): + + self.get_items(request) + + n = 0 + + return Response({"success": "Added stock for {n} items".format(n=n)}) + + class StockTransfer(APIView): """ API endpoint for performing stock movements """ @@ -575,7 +621,9 @@ stock_api_urls = [ url(r'location/', include(location_endpoints)), # These JSON endpoints have been replaced (for now) with server-side form rendering - 02/06/2019 - url(r'stocktake/?', StockStocktake.as_view(), name='api-stock-stocktake'), + url(r'count/?', StockCount.as_view(), name='api-stock-count'), + url(r'add/?', StockAdd.as_view(), name='api-stock-add'), + url(r'remove/?', StockRemove.as_view(), name='api-stock-remove'), # url(r'transfer/?', StockTransfer.as_view(), name='api-stock-transfer'), url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'), diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index c76e016668..e85997ae1d 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -70,33 +70,76 @@ class StocktakeTest(APITestCase): Series of tests for the Stocktake API """ + fixtures = [ + 'category', + 'part', + 'company', + 'location', + 'supplier_part', + 'stock', + ] + def setUp(self): User = get_user_model() User.objects.create_user('testuser', 'test@testing.com', 'password') self.client.login(username='testuser', password='password') - def doPost(self, data={}): - url = reverse('api-stock-stocktake') + def doPost(self, url, data={}): response = self.client.post(url, data=data, format='json') return response def test_action(self): + """ + Test each stocktake action endpoint, + for validation + """ - data = {} + for endpoint in ['api-stock-count', 'api-stock-add', 'api-stock-remove']: - # POST without any action - response = self.doPost(data) - self.assertContains(response, "action must be provided", status_code=status.HTTP_400_BAD_REQUEST) + url = reverse(endpoint) - data['action'] = 'fake' + data = {} - # POST with an invalid action - response = self.doPost(data) - self.assertContains(response, "must be one of", status_code=status.HTTP_400_BAD_REQUEST) + # POST with a valid action + response = self.doPost(url, data) + self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST) - data['action'] = 'count' + data['items'] = [{ + 'no': 'aa' + }] - # POST with a valid action - response = self.doPost(data) - self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + # POST without a PK + response = self.doPost(url, data) + self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST) + + # POST with a PK but no quantity + data['items'] = [{ + 'pk': 10 + }] + + response = self.doPost(url, data) + self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST) + + data['items'] = [{ + 'pk': 1234 + }] + + response = self.doPost(url, data) + self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST) + + data['items'] = [{ + 'pk': 1234, + 'quantity': '10x0d' + }] + + response = self.doPost(url, data) + self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST) + + data['items'] = [{ + 'pk': 1234, + 'quantity': "-1.234" + }] + + response = self.doPost(url, data) + self.assertContains(response, 'must be greater than zero', status_code=status.HTTP_400_BAD_REQUEST)