mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Simplifiy stock adjustment APIs
- Separate API endpoints for count / add / remove / transfer - Unit testing
This commit is contained in:
parent
1b3f8a9309
commit
58a0f40889
@ -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'),
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user