mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 11:10:54 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
@ -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,144 +96,154 @@ 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:
|
||||
- stocktake: 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:
|
||||
|
||||
if not type(entry) == dict:
|
||||
raise ValidationError({'error': 'Improperly formatted data'})
|
||||
|
||||
try:
|
||||
pk = entry.get('pk', None)
|
||||
item = StockItem.objects.get(pk=pk)
|
||||
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 not be less than zero'})
|
||||
|
||||
self.items.append({
|
||||
'item': item,
|
||||
'quantity': quantity
|
||||
})
|
||||
|
||||
self.notes = str(request.data.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 = ['stocktake', '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:
|
||||
notes = request.data['notes']
|
||||
self.get_items(request)
|
||||
|
||||
n = 0
|
||||
|
||||
for item in items:
|
||||
quantity = int(item['quantity'])
|
||||
for item in self.items:
|
||||
|
||||
if action == u'stocktake':
|
||||
if item['item'].stocktake(quantity, request.user, notes=notes):
|
||||
n += 1
|
||||
elif action == u'remove':
|
||||
if item['item'].take_stock(quantity, request.user, notes=notes):
|
||||
n += 1
|
||||
elif action == u'add':
|
||||
if item['item'].add_stock(quantity, request.user, notes=notes):
|
||||
n += 1
|
||||
if item['item'].stocktake(item['quantity'], request.user, notes=self.notes):
|
||||
n += 1
|
||||
|
||||
return Response({'success': 'Updated stock for {n} items'.format(n=n)})
|
||||
|
||||
|
||||
class StockMove(APIView):
|
||||
""" API endpoint for performing stock movements """
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
class StockAdd(StockAdjust):
|
||||
"""
|
||||
Endpoint for adding stock
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.get_items(request)
|
||||
|
||||
n = 0
|
||||
|
||||
for item in self.items:
|
||||
if item['item'].add_stock(item['quantity'], request.user, notes=self.notes):
|
||||
n += 1
|
||||
|
||||
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
|
||||
|
||||
for item in self.items:
|
||||
|
||||
if item['item'].take_stock(item['quantity'], request.user, notes=self.notes):
|
||||
n += 1
|
||||
|
||||
return Response({"success": "Removed stock for {n} items".format(n=n)})
|
||||
|
||||
|
||||
class StockTransfer(StockAdjust):
|
||||
"""
|
||||
API endpoint for performing stock movements
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.get_items(request)
|
||||
|
||||
data = request.data
|
||||
|
||||
if 'location' not in data:
|
||||
raise ValidationError({'location': 'Destination must be specified'})
|
||||
|
||||
try:
|
||||
loc_id = int(data.get('location'))
|
||||
except ValueError:
|
||||
raise ValidationError({'location': 'Integer ID required'})
|
||||
location = StockLocation.objects.get(pk=data.get('location', None))
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
raise ValidationError({'location': 'Valid location must be specified'})
|
||||
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=loc_id)
|
||||
except StockLocation.DoesNotExist:
|
||||
raise ValidationError({'location': 'Location does not exist'})
|
||||
n = 0
|
||||
|
||||
if 'stock' not in data:
|
||||
raise ValidationError({'stock': 'Stock list must be specified'})
|
||||
|
||||
stock_list = data.get('stock')
|
||||
for item in self.items:
|
||||
|
||||
if type(stock_list) is not list:
|
||||
raise ValidationError({'stock': 'Stock must be supplied as a list'})
|
||||
# If quantity is not specified, move the entire stock
|
||||
if item['quantity'] in [0, None]:
|
||||
item['quantity'] = item['item'].quantity
|
||||
|
||||
if 'notes' not in data:
|
||||
raise ValidationError({'notes': 'Notes field must be supplied'})
|
||||
if item['item'].move(location, self.notes, request.user, quantity=item['quantity']):
|
||||
n += 1
|
||||
|
||||
for item in stock_list:
|
||||
try:
|
||||
stock_id = int(item['pk'])
|
||||
if 'quantity' in item:
|
||||
quantity = int(item['quantity'])
|
||||
else:
|
||||
# If quantity not supplied, we'll move the entire stock
|
||||
quantity = None
|
||||
except ValueError:
|
||||
# Ignore this one
|
||||
continue
|
||||
|
||||
# Ignore a zero quantity movement
|
||||
if quantity <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
stock = StockItem.objects.get(pk=stock_id)
|
||||
except StockItem.DoesNotExist:
|
||||
continue
|
||||
|
||||
if quantity is None:
|
||||
quantity = stock.quantity
|
||||
|
||||
stock.move(location, data.get('notes'), request.user, quantity=quantity)
|
||||
|
||||
return Response({'success': 'Moved parts to {loc}'.format(
|
||||
loc=str(location)
|
||||
return Response({'success': 'Moved {n} parts to {loc}'.format(
|
||||
n=n,
|
||||
loc=str(location),
|
||||
)})
|
||||
|
||||
|
||||
@ -512,22 +523,6 @@ class StockList(generics.ListCreateAPIView):
|
||||
]
|
||||
|
||||
|
||||
class StockStocktakeEndpoint(generics.UpdateAPIView):
|
||||
""" API endpoint for performing stocktake """
|
||||
|
||||
queryset = StockItem.objects.all()
|
||||
serializer_class = StockQuantitySerializer
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
object = self.get_object()
|
||||
object.stocktake(request.data['quantity'], request.user)
|
||||
|
||||
serializer = self.get_serializer(object)
|
||||
|
||||
return response.Response(serializer.data)
|
||||
|
||||
|
||||
class StockTrackingList(generics.ListCreateAPIView):
|
||||
""" API endpoint for list view of StockItemTracking objects.
|
||||
|
||||
@ -591,8 +586,10 @@ 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'move/?', StockMove.as_view(), name='api-stock-move'),
|
||||
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'),
|
||||
|
||||
|
@ -11,7 +11,7 @@
|
||||
{% if item.serialized %}
|
||||
<p><i>{{ item.part.full_name}} # {{ item.serial }}</i></p>
|
||||
{% else %}
|
||||
<p><i>{{ item.quantity }} × {{ item.part.full_name }}</i></p>
|
||||
<p><i>{% decimal item.quantity %} × {{ item.part.full_name }}</i></p>
|
||||
{% endif %}
|
||||
<p>
|
||||
<div class='btn-group'>
|
||||
|
@ -63,3 +63,119 @@ class StockItemTest(APITestCase):
|
||||
def test_get_stock_list(self):
|
||||
response = self.client.get(self.list_url, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
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, url, data={}):
|
||||
response = self.client.post(url, data=data, format='json')
|
||||
|
||||
return response
|
||||
|
||||
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 = {}
|
||||
|
||||
# POST with a valid action
|
||||
response = self.doPost(url, data)
|
||||
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 not be less than zero', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Test with a single item
|
||||
data = {
|
||||
'item': {
|
||||
'pk': 1234,
|
||||
'quantity': '10',
|
||||
}
|
||||
}
|
||||
|
||||
response = self.doPost(url, data)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_transfer(self):
|
||||
"""
|
||||
Test stock transfers
|
||||
"""
|
||||
|
||||
data = {
|
||||
'item': {
|
||||
'pk': 1234,
|
||||
'quantity': 10,
|
||||
},
|
||||
'location': 1,
|
||||
'notes': "Moving to a new location"
|
||||
}
|
||||
|
||||
url = reverse('api-stock-transfer')
|
||||
|
||||
response = self.doPost(url, data)
|
||||
self.assertContains(response, "Moved 1 parts to", status_code=status.HTTP_200_OK)
|
||||
|
||||
# Now try one which will fail due to a bad location
|
||||
data['location'] = 'not a location'
|
||||
|
||||
response = self.doPost(url, data)
|
||||
self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
Reference in New Issue
Block a user