Simplifiy stock adjustment APIs

- Separate API endpoints for count / add / remove / transfer
- Unit testing
This commit is contained in:
Oliver Walters 2020-04-09 22:24:05 +10:00
parent 1b3f8a9309
commit 58a0f40889
2 changed files with 153 additions and 62 deletions

View File

@ -14,7 +14,7 @@ from .models import StockItemTracking
from part.models import Part, PartCategory from part.models import Part, PartCategory
from .serializers import StockItemSerializer, StockQuantitySerializer from .serializers import StockItemSerializer
from .serializers import LocationSerializer from .serializers import LocationSerializer
from .serializers import StockTrackingSerializer from .serializers import StockTrackingSerializer
@ -23,11 +23,12 @@ from InvenTree.helpers import str2bool, isNull
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus
import os import os
from decimal import Decimal, InvalidOperation
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import generics, response, filters, permissions from rest_framework import generics, filters, permissions
class StockCategoryTree(TreeSerializer): class StockCategoryTree(TreeSerializer):
@ -95,60 +96,74 @@ class StockFilter(FilterSet):
fields = ['quantity', 'part', 'location'] fields = ['quantity', 'part', 'location']
class StockStocktake(APIView): class StockAdjust(APIView):
""" Stocktake API endpoint provides stock update of multiple items simultaneously. """
The 'action' field tells the type of stock action to perform: A generic class for handling stocktake actions.
- count: Count the stock item(s)
- remove: Remove the quantity provided from stock Subclasses exist for:
- add: Add the quantity provided from stock
- StockCount: count stock items
- StockAdd: add stock items
- StockRemove: remove stock items
- StockTransfer: transfer stock items
""" """
permission_classes = [ permission_classes = [
permissions.IsAuthenticated, 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): def post(self, request, *args, **kwargs):
if 'action' not in request.data: self.get_items(request)
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 = ''
"""
if 'notes' in request.data: if 'notes' in request.data:
notes = request.data['notes'] notes = request.data['notes']
@ -166,10 +181,41 @@ class StockStocktake(APIView):
elif action == u'add': elif action == u'add':
if item['item'].add_stock(quantity, request.user, notes=notes): if item['item'].add_stock(quantity, request.user, notes=notes):
n += 1 n += 1
"""
n = 0
return Response({'success': 'Updated stock for {n} items'.format(n=n)}) 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): class StockTransfer(APIView):
""" API endpoint for performing stock movements """ """ API endpoint for performing stock movements """
@ -575,7 +621,9 @@ stock_api_urls = [
url(r'location/', include(location_endpoints)), url(r'location/', include(location_endpoints)),
# These JSON endpoints have been replaced (for now) with server-side form rendering - 02/06/2019 # 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'transfer/?', StockTransfer.as_view(), name='api-stock-transfer'),
url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'), url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'),

View File

@ -70,33 +70,76 @@ class StocktakeTest(APITestCase):
Series of tests for the Stocktake API Series of tests for the Stocktake API
""" """
fixtures = [
'category',
'part',
'company',
'location',
'supplier_part',
'stock',
]
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
User.objects.create_user('testuser', 'test@testing.com', 'password') User.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password') self.client.login(username='testuser', password='password')
def doPost(self, data={}): def doPost(self, url, data={}):
url = reverse('api-stock-stocktake')
response = self.client.post(url, data=data, format='json') response = self.client.post(url, data=data, format='json')
return response return response
def test_action(self): def test_action(self):
"""
Test each stocktake action endpoint,
for validation
"""
for endpoint in ['api-stock-count', 'api-stock-add', 'api-stock-remove']:
url = reverse(endpoint)
data = {} data = {}
# POST without any action
response = self.doPost(data)
self.assertContains(response, "action must be provided", status_code=status.HTTP_400_BAD_REQUEST)
data['action'] = 'fake'
# POST with an invalid action
response = self.doPost(data)
self.assertContains(response, "must be one of", status_code=status.HTTP_400_BAD_REQUEST)
data['action'] = 'count'
# POST with a valid action # POST with a valid action
response = self.doPost(data) response = self.doPost(url, data)
self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST) self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST)
data['items'] = [{
'no': 'aa'
}]
# 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)