mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2103 from SchrodingersGat/stock-adjustment-api
Stock adjustment api
This commit is contained in:
commit
c2ad9c4765
@ -10,11 +10,15 @@ import common.models
|
||||
|
||||
INVENTREE_SW_VERSION = "0.6.0 dev"
|
||||
|
||||
INVENTREE_API_VERSION = 13
|
||||
INVENTREE_API_VERSION = 14
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v14 -> 2021-20-05
|
||||
- Stock adjustment actions API is improved, using native DRF serializer support
|
||||
- However adjustment actions now only support 'pk' as a lookup field
|
||||
|
||||
v13 -> 2021-10-05
|
||||
- Adds API endpoint to allocate stock items against a BuildOrder
|
||||
- Updates StockItem API with improved filtering against BomItem data
|
||||
|
@ -372,7 +372,7 @@
|
||||
{
|
||||
success: function(items) {
|
||||
adjustStock(action, items, {
|
||||
onSuccess: function() {
|
||||
success: function() {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
|
@ -5,7 +5,6 @@ JSON API for the Stock app
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
@ -17,7 +16,6 @@ from django.db.models import Q
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import generics, filters, permissions
|
||||
|
||||
@ -41,11 +39,7 @@ from order.serializers import POSerializer
|
||||
import common.settings
|
||||
import common.models
|
||||
|
||||
from .serializers import StockItemSerializer
|
||||
from .serializers import LocationSerializer, LocationBriefSerializer
|
||||
from .serializers import StockTrackingSerializer
|
||||
from .serializers import StockItemAttachmentSerializer
|
||||
from .serializers import StockItemTestResultSerializer
|
||||
import stock.serializers as StockSerializers
|
||||
|
||||
from InvenTree.views import TreeSerializer
|
||||
from InvenTree.helpers import str2bool, isNull
|
||||
@ -83,12 +77,12 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockItem.objects.all()
|
||||
serializer_class = StockItemSerializer
|
||||
serializer_class = StockSerializers.StockItemSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = StockItemSerializer.annotate_queryset(queryset)
|
||||
queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -124,7 +118,7 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
instance.mark_for_deletion()
|
||||
|
||||
|
||||
class StockAdjust(APIView):
|
||||
class StockAdjustView(generics.CreateAPIView):
|
||||
"""
|
||||
A generic class for handling stocktake actions.
|
||||
|
||||
@ -138,184 +132,57 @@ class StockAdjust(APIView):
|
||||
|
||||
queryset = StockItem.objects.none()
|
||||
|
||||
allow_missing_quantity = False
|
||||
def get_serializer_context(self):
|
||||
|
||||
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.
|
||||
"""
|
||||
context = super().get_serializer_context()
|
||||
|
||||
_items = []
|
||||
context['request'] = self.request
|
||||
|
||||
if 'item' in request.data:
|
||||
_items = [request.data['item']]
|
||||
elif 'items' in request.data:
|
||||
_items = request.data['items']
|
||||
else:
|
||||
_items = []
|
||||
|
||||
if len(_items) == 0:
|
||||
raise ValidationError(_('Request must contain list of stock items'))
|
||||
|
||||
# List of validated items
|
||||
self.items = []
|
||||
|
||||
for entry in _items:
|
||||
|
||||
if not type(entry) == dict:
|
||||
raise ValidationError(_('Improperly formatted data'))
|
||||
|
||||
# Look for a 'pk' value (use 'id' as a backup)
|
||||
pk = entry.get('pk', entry.get('id', None))
|
||||
|
||||
try:
|
||||
pk = int(pk)
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationError(_('Each entry must contain a valid integer primary-key'))
|
||||
|
||||
try:
|
||||
item = StockItem.objects.get(pk=pk)
|
||||
except (StockItem.DoesNotExist):
|
||||
raise ValidationError({
|
||||
pk: [_('Primary key does not match valid stock item')]
|
||||
})
|
||||
|
||||
if self.allow_missing_quantity and 'quantity' not in entry:
|
||||
entry['quantity'] = item.quantity
|
||||
|
||||
try:
|
||||
quantity = Decimal(str(entry.get('quantity', None)))
|
||||
except (ValueError, TypeError, InvalidOperation):
|
||||
raise ValidationError({
|
||||
pk: [_('Invalid quantity value')]
|
||||
})
|
||||
|
||||
if quantity < 0:
|
||||
raise ValidationError({
|
||||
pk: [_('Quantity must not be less than zero')]
|
||||
})
|
||||
|
||||
self.items.append({
|
||||
'item': item,
|
||||
'quantity': quantity
|
||||
})
|
||||
|
||||
# Extract 'notes' field
|
||||
self.notes = str(request.data.get('notes', ''))
|
||||
return context
|
||||
|
||||
|
||||
class StockCount(StockAdjust):
|
||||
class StockCount(StockAdjustView):
|
||||
"""
|
||||
Endpoint for counting stock (performing a stocktake).
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.get_items(request)
|
||||
|
||||
n = 0
|
||||
|
||||
for item in self.items:
|
||||
|
||||
if item['item'].stocktake(item['quantity'], request.user, notes=self.notes):
|
||||
n += 1
|
||||
|
||||
return Response({'success': _('Updated stock for {n} items').format(n=n)})
|
||||
serializer_class = StockSerializers.StockCountSerializer
|
||||
|
||||
|
||||
class StockAdd(StockAdjust):
|
||||
class StockAdd(StockAdjustView):
|
||||
"""
|
||||
Endpoint for adding a quantity of stock to an existing StockItem
|
||||
"""
|
||||
|
||||
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)})
|
||||
serializer_class = StockSerializers.StockAddSerializer
|
||||
|
||||
|
||||
class StockRemove(StockAdjust):
|
||||
class StockRemove(StockAdjustView):
|
||||
"""
|
||||
Endpoint for removing a quantity of stock from an existing StockItem.
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
self.get_items(request)
|
||||
|
||||
n = 0
|
||||
|
||||
for item in self.items:
|
||||
|
||||
if item['quantity'] > item['item'].quantity:
|
||||
raise ValidationError({
|
||||
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
|
||||
})
|
||||
|
||||
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)})
|
||||
serializer_class = StockSerializers.StockRemoveSerializer
|
||||
|
||||
|
||||
class StockTransfer(StockAdjust):
|
||||
class StockTransfer(StockAdjustView):
|
||||
"""
|
||||
API endpoint for performing stock movements
|
||||
"""
|
||||
|
||||
allow_missing_quantity = True
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
data = request.data
|
||||
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=data.get('location', None))
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
raise ValidationError({'location': [_('Valid location must be specified')]})
|
||||
|
||||
n = 0
|
||||
|
||||
self.get_items(request)
|
||||
|
||||
for item in self.items:
|
||||
|
||||
if item['quantity'] > item['item'].quantity:
|
||||
raise ValidationError({
|
||||
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
|
||||
})
|
||||
|
||||
# If quantity is not specified, move the entire stock
|
||||
if item['quantity'] in [0, None]:
|
||||
item['quantity'] = item['item'].quantity
|
||||
|
||||
if item['item'].move(location, self.notes, request.user, quantity=item['quantity']):
|
||||
n += 1
|
||||
|
||||
return Response({'success': _('Moved {n} parts to {loc}').format(
|
||||
n=n,
|
||||
loc=str(location),
|
||||
)})
|
||||
serializer_class = StockSerializers.StockTransferSerializer
|
||||
|
||||
|
||||
class StockLocationList(generics.ListCreateAPIView):
|
||||
""" API endpoint for list view of StockLocation objects:
|
||||
"""
|
||||
API endpoint for list view of StockLocation objects:
|
||||
|
||||
- GET: Return list of StockLocation objects
|
||||
- POST: Create a new StockLocation
|
||||
"""
|
||||
|
||||
queryset = StockLocation.objects.all()
|
||||
serializer_class = LocationSerializer
|
||||
serializer_class = StockSerializers.LocationSerializer
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
@ -517,7 +384,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
- POST: Create a new StockItem
|
||||
"""
|
||||
|
||||
serializer_class = StockItemSerializer
|
||||
serializer_class = StockSerializers.StockItemSerializer
|
||||
queryset = StockItem.objects.all()
|
||||
filterset_class = StockFilter
|
||||
|
||||
@ -639,7 +506,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
|
||||
# Serialize each StockLocation object
|
||||
for location in locations:
|
||||
location_map[location.pk] = LocationBriefSerializer(location).data
|
||||
location_map[location.pk] = StockSerializers.LocationBriefSerializer(location).data
|
||||
|
||||
# Now update each StockItem with the related StockLocation data
|
||||
for stock_item in data:
|
||||
@ -665,7 +532,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = StockItemSerializer.annotate_queryset(queryset)
|
||||
queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
# Do not expose StockItem objects which are scheduled for deletion
|
||||
queryset = queryset.filter(scheduled_for_deletion=False)
|
||||
@ -954,7 +821,7 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
"""
|
||||
|
||||
queryset = StockItemAttachment.objects.all()
|
||||
serializer_class = StockItemAttachmentSerializer
|
||||
serializer_class = StockSerializers.StockItemAttachmentSerializer
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
@ -973,7 +840,7 @@ class StockAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix
|
||||
"""
|
||||
|
||||
queryset = StockItemAttachment.objects.all()
|
||||
serializer_class = StockItemAttachmentSerializer
|
||||
serializer_class = StockSerializers.StockItemAttachmentSerializer
|
||||
|
||||
|
||||
class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
@ -982,7 +849,7 @@ class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockItemTestResult.objects.all()
|
||||
serializer_class = StockItemTestResultSerializer
|
||||
serializer_class = StockSerializers.StockItemTestResultSerializer
|
||||
|
||||
|
||||
class StockItemTestResultList(generics.ListCreateAPIView):
|
||||
@ -991,7 +858,7 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockItemTestResult.objects.all()
|
||||
serializer_class = StockItemTestResultSerializer
|
||||
serializer_class = StockSerializers.StockItemTestResultSerializer
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
@ -1039,7 +906,7 @@ class StockTrackingDetail(generics.RetrieveAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockItemTracking.objects.all()
|
||||
serializer_class = StockTrackingSerializer
|
||||
serializer_class = StockSerializers.StockTrackingSerializer
|
||||
|
||||
|
||||
class StockTrackingList(generics.ListAPIView):
|
||||
@ -1052,7 +919,7 @@ class StockTrackingList(generics.ListAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockItemTracking.objects.all()
|
||||
serializer_class = StockTrackingSerializer
|
||||
serializer_class = StockSerializers.StockTrackingSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
try:
|
||||
@ -1088,7 +955,7 @@ class StockTrackingList(generics.ListAPIView):
|
||||
if 'location' in deltas:
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=deltas['location'])
|
||||
serializer = LocationSerializer(location)
|
||||
serializer = StockSerializers.LocationSerializer(location)
|
||||
deltas['location_detail'] = serializer.data
|
||||
except:
|
||||
pass
|
||||
@ -1097,7 +964,7 @@ class StockTrackingList(generics.ListAPIView):
|
||||
if 'stockitem' in deltas:
|
||||
try:
|
||||
stockitem = StockItem.objects.get(pk=deltas['stockitem'])
|
||||
serializer = StockItemSerializer(stockitem)
|
||||
serializer = StockSerializers.StockItemSerializer(stockitem)
|
||||
deltas['stockitem_detail'] = serializer.data
|
||||
except:
|
||||
pass
|
||||
@ -1179,7 +1046,7 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
|
||||
queryset = StockLocation.objects.all()
|
||||
serializer_class = LocationSerializer
|
||||
serializer_class = StockSerializers.LocationSerializer
|
||||
|
||||
|
||||
stock_api_urls = [
|
||||
|
@ -2,27 +2,29 @@
|
||||
JSON serializers for Stock app
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, timedelta
|
||||
from django.db import transaction
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import Case, When, Value
|
||||
from django.db.models import BooleanField
|
||||
from django.db.models import Q
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from sql_util.utils import SubquerySum, SubqueryCount
|
||||
|
||||
from .models import StockItem, StockLocation
|
||||
from .models import StockItemTracking
|
||||
from .models import StockItemAttachment
|
||||
from .models import StockItemTestResult
|
||||
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from django.db.models import Case, When, Value
|
||||
from django.db.models import BooleanField
|
||||
from django.db.models import Q
|
||||
|
||||
from sql_util.utils import SubquerySum, SubqueryCount
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import common.models
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
|
||||
@ -396,3 +398,196 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
|
||||
'label',
|
||||
'tracking_type',
|
||||
]
|
||||
|
||||
|
||||
class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for a single StockItem within a stock adjument request.
|
||||
|
||||
Fields:
|
||||
- item: StockItem object
|
||||
- quantity: Numerical quantity
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'item',
|
||||
'quantity'
|
||||
]
|
||||
|
||||
pk = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label='stock_item',
|
||||
help_text=_('StockItem primary key value')
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
required=True
|
||||
)
|
||||
|
||||
|
||||
class StockAdjustmentSerializer(serializers.Serializer):
|
||||
"""
|
||||
Base class for managing stock adjustment actions via the API
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'items',
|
||||
'notes',
|
||||
]
|
||||
|
||||
items = StockAdjustmentItemSerializer(many=True)
|
||||
|
||||
notes = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
label=_("Notes"),
|
||||
help_text=_("Stock transaction notes"),
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
super().validate(data)
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
if len(items) == 0:
|
||||
raise ValidationError(_("A list of stock items must be provided"))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class StockCountSerializer(StockAdjustmentSerializer):
|
||||
"""
|
||||
Serializer for counting stock items
|
||||
"""
|
||||
|
||||
def save(self):
|
||||
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
items = data['items']
|
||||
notes = data.get('notes', '')
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
stock_item.stocktake(
|
||||
quantity,
|
||||
request.user,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
|
||||
class StockAddSerializer(StockAdjustmentSerializer):
|
||||
"""
|
||||
Serializer for adding stock to stock item(s)
|
||||
"""
|
||||
|
||||
def save(self):
|
||||
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
notes = data.get('notes', '')
|
||||
|
||||
with transaction.atomic():
|
||||
for item in data['items']:
|
||||
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
stock_item.add_stock(
|
||||
quantity,
|
||||
request.user,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
|
||||
class StockRemoveSerializer(StockAdjustmentSerializer):
|
||||
"""
|
||||
Serializer for removing stock from stock item(s)
|
||||
"""
|
||||
|
||||
def save(self):
|
||||
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
notes = data.get('notes', '')
|
||||
|
||||
with transaction.atomic():
|
||||
for item in data['items']:
|
||||
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
stock_item.take_stock(
|
||||
quantity,
|
||||
request.user,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
|
||||
class StockTransferSerializer(StockAdjustmentSerializer):
|
||||
"""
|
||||
Serializer for transferring (moving) stock item(s)
|
||||
"""
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Destination stock location'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'items',
|
||||
'notes',
|
||||
'location',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
super().validate(data)
|
||||
|
||||
# TODO: Any specific validation of location field?
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
items = data['items']
|
||||
notes = data.get('notes', '')
|
||||
location = data['location']
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
stock_item.move(
|
||||
location,
|
||||
notes,
|
||||
request.user,
|
||||
quantity=quantity
|
||||
)
|
||||
|
@ -561,7 +561,7 @@ function itemAdjust(action) {
|
||||
{
|
||||
success: function(item) {
|
||||
adjustStock(action, [item], {
|
||||
onSuccess: function() {
|
||||
success: function() {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
|
@ -287,7 +287,7 @@
|
||||
{
|
||||
success: function(items) {
|
||||
adjustStock(action, items, {
|
||||
onSuccess: function() {
|
||||
success: function() {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
|
@ -513,31 +513,34 @@ class StocktakeTest(StockAPITestCase):
|
||||
|
||||
# POST with a valid action
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
self.assertIn("This field is required", str(response.data["items"]))
|
||||
|
||||
data['items'] = [{
|
||||
'no': 'aa'
|
||||
}]
|
||||
|
||||
# POST without a PK
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'must contain a valid integer primary-key', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
response = self.post(url, data, expected_code=400)
|
||||
|
||||
self.assertIn('This field is required', str(response.data))
|
||||
|
||||
# POST with an invalid PK
|
||||
data['items'] = [{
|
||||
'pk': 10
|
||||
}]
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'does not match valid stock item', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
response = self.post(url, data, expected_code=400)
|
||||
|
||||
self.assertContains(response, 'object does not exist', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# POST with missing quantity value
|
||||
data['items'] = [{
|
||||
'pk': 1234
|
||||
}]
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertContains(response, 'This field is required', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# POST with an invalid quantity value
|
||||
data['items'] = [{
|
||||
@ -546,7 +549,7 @@ class StocktakeTest(StockAPITestCase):
|
||||
}]
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
self.assertContains(response, 'A valid number is required', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
data['items'] = [{
|
||||
'pk': 1234,
|
||||
@ -554,18 +557,7 @@ class StocktakeTest(StockAPITestCase):
|
||||
}]
|
||||
|
||||
response = self.post(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.post(url, data)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertContains(response, 'Ensure this value is greater than or equal to 0', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_transfer(self):
|
||||
"""
|
||||
@ -573,24 +565,27 @@ class StocktakeTest(StockAPITestCase):
|
||||
"""
|
||||
|
||||
data = {
|
||||
'item': {
|
||||
'items': [
|
||||
{
|
||||
'pk': 1234,
|
||||
'quantity': 10,
|
||||
},
|
||||
}
|
||||
],
|
||||
'location': 1,
|
||||
'notes': "Moving to a new location"
|
||||
}
|
||||
|
||||
url = reverse('api-stock-transfer')
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, "Moved 1 parts to", status_code=status.HTTP_200_OK)
|
||||
# This should succeed
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
# Now try one which will fail due to a bad location
|
||||
data['location'] = 'not a location'
|
||||
|
||||
response = self.post(url, data)
|
||||
self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
response = self.post(url, data, expected_code=400)
|
||||
|
||||
self.assertContains(response, 'Incorrect type. Expected pk value', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class StockItemDeletionTest(StockAPITestCase):
|
||||
|
@ -7,7 +7,7 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.views.generic.edit import FormMixin
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.forms.models import model_to_dict
|
||||
from django.forms import HiddenInput
|
||||
from django.urls import reverse
|
||||
@ -145,29 +145,6 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class StockItemNotes(InvenTreeRoleMixin, UpdateView):
|
||||
""" View for editing the 'notes' field of a StockItem object """
|
||||
|
||||
context_object_name = 'item'
|
||||
template_name = 'stock/item_notes.html'
|
||||
model = StockItem
|
||||
|
||||
role_required = 'stock.view'
|
||||
|
||||
fields = ['notes']
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('stock-item-notes', kwargs={'pk': self.get_object().id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
|
||||
ctx['editing'] = str2bool(self.request.GET.get('edit', ''))
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class StockLocationEdit(AjaxUpdateView):
|
||||
"""
|
||||
View for editing details of a StockLocation.
|
||||
|
@ -4,15 +4,12 @@
|
||||
|
||||
/* globals
|
||||
attachSelect,
|
||||
attachToggle,
|
||||
blankImage,
|
||||
enableField,
|
||||
clearField,
|
||||
clearFieldOptions,
|
||||
closeModal,
|
||||
constructField,
|
||||
constructFormBody,
|
||||
constructNumberInput,
|
||||
createNewModal,
|
||||
getFormFieldValue,
|
||||
global_settings,
|
||||
handleFormErrors,
|
||||
@ -247,7 +244,7 @@ function adjustStock(action, items, options={}) {
|
||||
break;
|
||||
}
|
||||
|
||||
var image = item.part_detail.thumbnail || item.part_detail.image || blankImage();
|
||||
var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image);
|
||||
|
||||
var status = stockStatusDisplay(item.status, {
|
||||
classes: 'float-right'
|
||||
@ -268,14 +265,18 @@ function adjustStock(action, items, options={}) {
|
||||
var actionInput = '';
|
||||
|
||||
if (actionTitle != null) {
|
||||
actionInput = constructNumberInput(
|
||||
item.pk,
|
||||
actionInput = constructField(
|
||||
`items_quantity_${pk}`,
|
||||
{
|
||||
value: value,
|
||||
type: 'decimal',
|
||||
min_value: minValue,
|
||||
max_value: maxValue,
|
||||
read_only: readonly,
|
||||
value: value,
|
||||
title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
hideLabels: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -293,7 +294,7 @@ function adjustStock(action, items, options={}) {
|
||||
|
||||
html += `
|
||||
<tr id='stock_item_${pk}' class='stock-item-row'>
|
||||
<td id='part_${pk}'><img src='${image}' class='hover-img-thumb'> ${item.part_detail.full_name}</td>
|
||||
<td id='part_${pk}'>${thumb} ${item.part_detail.full_name}</td>
|
||||
<td id='stock_${pk}'>${quantity}${status}</td>
|
||||
<td id='location_${pk}'>${location}</td>
|
||||
<td id='action_${pk}'>
|
||||
@ -319,50 +320,89 @@ function adjustStock(action, items, options={}) {
|
||||
|
||||
html += `</tbody></table>`;
|
||||
|
||||
var modal = createNewModal({
|
||||
title: formTitle,
|
||||
});
|
||||
var extraFields = {};
|
||||
|
||||
// Extra fields
|
||||
var extraFields = {
|
||||
location: {
|
||||
label: '{% trans "Location" %}',
|
||||
help_text: '{% trans "Select destination stock location" %}',
|
||||
type: 'related field',
|
||||
required: true,
|
||||
api_url: `/api/stock/location/`,
|
||||
model: 'stocklocation',
|
||||
name: 'location',
|
||||
},
|
||||
notes: {
|
||||
label: '{% trans "Notes" %}',
|
||||
help_text: '{% trans "Stock transaction notes" %}',
|
||||
type: 'string',
|
||||
name: 'notes',
|
||||
}
|
||||
};
|
||||
|
||||
if (!specifyLocation) {
|
||||
delete extraFields.location;
|
||||
if (specifyLocation) {
|
||||
extraFields.location = {};
|
||||
}
|
||||
|
||||
constructFormBody({}, {
|
||||
preFormContent: html,
|
||||
if (action != 'delete') {
|
||||
extraFields.notes = {};
|
||||
}
|
||||
|
||||
constructForm(url, {
|
||||
method: 'POST',
|
||||
fields: extraFields,
|
||||
preFormContent: html,
|
||||
confirm: true,
|
||||
confirmMessage: '{% trans "Confirm stock adjustment" %}',
|
||||
modal: modal,
|
||||
onSubmit: function(fields) {
|
||||
title: formTitle,
|
||||
afterRender: function(fields, opts) {
|
||||
// Add button callbacks to remove rows
|
||||
$(opts.modal).find('.button-stock-item-remove').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
// "Delete" action gets handled differently
|
||||
if (action == 'delete') {
|
||||
$(opts.modal).find(`#stock_item_${pk}`).remove();
|
||||
});
|
||||
|
||||
var requests = [];
|
||||
// Initialize "location" field
|
||||
if (specifyLocation) {
|
||||
initializeRelatedField(
|
||||
{
|
||||
name: 'location',
|
||||
type: 'related field',
|
||||
model: 'stocklocation',
|
||||
required: true,
|
||||
},
|
||||
null,
|
||||
opts
|
||||
);
|
||||
}
|
||||
},
|
||||
onSubmit: function(fields, opts) {
|
||||
|
||||
// Extract data elements from the form
|
||||
var data = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
if (action != 'delete') {
|
||||
data.notes = getFormFieldValue('notes', {}, opts);
|
||||
}
|
||||
|
||||
if (specifyLocation) {
|
||||
data.location = getFormFieldValue('location', {}, opts);
|
||||
}
|
||||
|
||||
var item_pk_values = [];
|
||||
|
||||
items.forEach(function(item) {
|
||||
var pk = item.pk;
|
||||
|
||||
// Does the row exist in the form?
|
||||
var row = $(opts.modal).find(`#stock_item_${pk}`);
|
||||
|
||||
if (row) {
|
||||
|
||||
item_pk_values.push(pk);
|
||||
|
||||
var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts);
|
||||
|
||||
data.items.push({
|
||||
pk: pk,
|
||||
quantity: quantity,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete action is handled differently
|
||||
if (action == 'delete') {
|
||||
var requests = [];
|
||||
|
||||
item_pk_values.forEach(function(pk) {
|
||||
requests.push(
|
||||
inventreeDelete(
|
||||
`/api/stock/${item.pk}/`,
|
||||
`/api/stock/${pk}/`,
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -370,72 +410,40 @@ function adjustStock(action, items, options={}) {
|
||||
// Wait for *all* the requests to complete
|
||||
$.when.apply($, requests).done(function() {
|
||||
// Destroy the modal window
|
||||
$(modal).modal('hide');
|
||||
$(opts.modal).modal('hide');
|
||||
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess();
|
||||
if (options.success) {
|
||||
options.success();
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Data to transmit
|
||||
var data = {
|
||||
items: [],
|
||||
opts.nested = {
|
||||
'items': item_pk_values,
|
||||
};
|
||||
|
||||
// Add values for each selected stock item
|
||||
items.forEach(function(item) {
|
||||
|
||||
var q = getFormFieldValue(item.pk, {}, {modal: modal});
|
||||
|
||||
if (q != null) {
|
||||
data.items.push({pk: item.pk, quantity: q});
|
||||
}
|
||||
});
|
||||
|
||||
// Add in extra field data
|
||||
for (var field_name in extraFields) {
|
||||
data[field_name] = getFormFieldValue(
|
||||
field_name,
|
||||
fields[field_name],
|
||||
{
|
||||
modal: modal,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
inventreePut(
|
||||
url,
|
||||
data,
|
||||
{
|
||||
method: 'POST',
|
||||
success: function() {
|
||||
success: function(response) {
|
||||
// Hide the modal
|
||||
$(opts.modal).modal('hide');
|
||||
|
||||
// Destroy the modal window
|
||||
$(modal).modal('hide');
|
||||
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess();
|
||||
if (options.success) {
|
||||
options.success(response);
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
switch (xhr.status) {
|
||||
case 400:
|
||||
|
||||
// Handle errors for standard fields
|
||||
handleFormErrors(
|
||||
xhr.responseJSON,
|
||||
extraFields,
|
||||
{
|
||||
modal: modal,
|
||||
}
|
||||
);
|
||||
|
||||
handleFormErrors(xhr.responseJSON, fields, opts);
|
||||
break;
|
||||
default:
|
||||
$(modal).modal('hide');
|
||||
$(opts.modal).modal('hide');
|
||||
showApiError(xhr);
|
||||
break;
|
||||
}
|
||||
@ -444,18 +452,6 @@ function adjustStock(action, items, options={}) {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Attach callbacks for the action buttons
|
||||
$(modal).find('.button-stock-item-remove').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
$(modal).find(`#stock_item_${pk}`).remove();
|
||||
});
|
||||
|
||||
attachToggle(modal);
|
||||
|
||||
$(modal + ' .select2-container').addClass('select-full-width');
|
||||
$(modal + ' .select2-container').css('width', '100%');
|
||||
}
|
||||
|
||||
|
||||
@ -1258,7 +1254,7 @@ function loadStockTable(table, options) {
|
||||
var items = $(table).bootstrapTable('getSelections');
|
||||
|
||||
adjustStock(action, items, {
|
||||
onSuccess: function() {
|
||||
success: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user