Adds API endpoint for serialization of stock items

This commit is contained in:
Oliver 2021-11-03 10:12:42 +11:00
parent 2b69d9c2af
commit be7b224f14
7 changed files with 198 additions and 150 deletions

View File

@ -101,6 +101,27 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
instance.mark_for_deletion() instance.mark_for_deletion()
class StockItemSerialize(generics.CreateAPIView):
"""
API endpoint for serializing a stock item
"""
queryset = StockItem.objects.none()
serializer_class = StockSerializers.SerializeStockItemSerializer
def get_serializer_context(self):
context = super().get_serializer_context()
context['request'] = self.request
try:
context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return context
class StockAdjustView(generics.CreateAPIView): class StockAdjustView(generics.CreateAPIView):
""" """
A generic class for handling stocktake actions. A generic class for handling stocktake actions.
@ -1126,8 +1147,11 @@ stock_api_urls = [
url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'), url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
])), ])),
# Detail for a single stock item # Detail views for a single stock item
url(r'^(?P<pk>\d+)/', StockDetail.as_view(), name='api-stock-detail'), url(r'^(?P<pk>\d+)/', include([
url(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
url(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
])),
# Anything else # Anything else
url(r'^.*$', StockList.as_view(), name='api-stock-list'), url(r'^.*$', StockList.as_view(), name='api-stock-list'),

View File

@ -9,6 +9,7 @@ from decimal import Decimal
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.db import transaction from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value from django.db.models import Case, When, Value
@ -27,14 +28,15 @@ from .models import StockItemTestResult
import common.models import common.models
from common.settings import currency_code_default, currency_code_mappings from common.settings import currency_code_default, currency_code_mappings
from company.serializers import SupplierPartSerializer from company.serializers import SupplierPartSerializer
import InvenTree.helpers
import InvenTree.serializers
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer, InvenTreeMoneySerializer
from InvenTree.serializers import InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField
class LocationBriefSerializer(InvenTreeModelSerializer): class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" """
Provides a brief serializer for a StockLocation object Provides a brief serializer for a StockLocation object
""" """
@ -48,7 +50,7 @@ class LocationBriefSerializer(InvenTreeModelSerializer):
] ]
class StockItemSerializerBrief(InvenTreeModelSerializer): class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
""" Brief serializers for a StockItem """ """ Brief serializers for a StockItem """
location_name = serializers.CharField(source='location', read_only=True) location_name = serializers.CharField(source='location', read_only=True)
@ -70,7 +72,7 @@ class StockItemSerializerBrief(InvenTreeModelSerializer):
] ]
class StockItemSerializer(InvenTreeModelSerializer): class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Serializer for a StockItem: """ Serializer for a StockItem:
- Includes serialization for the linked part - Includes serialization for the linked part
@ -146,7 +148,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False) required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
purchase_price = InvenTreeMoneySerializer( purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
label=_('Purchase Price'), label=_('Purchase Price'),
max_digits=19, decimal_places=4, max_digits=19, decimal_places=4,
allow_null=True, allow_null=True,
@ -247,14 +249,127 @@ class StockItemSerializer(InvenTreeModelSerializer):
] ]
class StockQuantitySerializer(InvenTreeModelSerializer): class SerializeStockItemSerializer(serializers.Serializer):
"""
A DRF serializer for "serializing" a StockItem.
(Sorry for the confusing naming...)
Here, "serializing" means splitting out a single StockItem,
into multiple single-quantity items with an assigned serial number
Note: The base StockItem object is provided to the serializer context
"""
class Meta: class Meta:
model = StockItem fields = [
fields = ('quantity',) 'quantity',
'serial_numbers',
'destination',
'notes',
]
quantity = serializers.IntegerField(
min_value=0,
required=True,
label=_('Quantity'),
help_text=_('Enter number of stock items to serialize'),
)
def validate_quantity(self, quantity):
item = self.context['item']
if quantity < 0:
raise ValidationError(_("Quantity must be greater than zero"))
if quantity > item.quantity:
q = item.quantity
raise ValidationError(_(f"Quantity must not exceed available stock quantity ({q})"))
return quantity
serial_numbers = serializers.CharField(
label=_('Serial Numbers'),
help_text=_('Enter serial numbers for new items'),
allow_blank=False,
required=True,
)
destination = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Destination stock location'),
)
notes = serializers.CharField(
required=False,
allow_blank=True,
label=_("Notes"),
help_text=_("Optional note field")
)
def validate(self, data):
"""
Check that the supplied serial numbers are valid
"""
data = super().validate(data)
item = self.context['item']
if not item.part.trackable:
raise ValidationError(_("Serial numbers cannot be assigned to this part"))
# Ensure the serial numbers are valid!
quantity = data['quantity']
serial_numbers = data['serial_numbers']
try:
serials = InvenTree.helpers.extract_serial_numbers(serial_numbers, quantity)
except DjangoValidationError as e:
raise ValidationError({
'serial_numbers': e.messages,
})
existing = item.part.find_conflicting_serial_numbers(serials)
if len(existing) > 0:
exists = ','.join([str(x) for x in existing])
error = _('Serial numbers already exist') + ": " + exists
raise ValidationError({
'serial_numbers': error,
})
return data
def save(self):
item = self.context['item']
request = self.context['request']
user = request.user
data = self.validated_data
serials = InvenTree.helpers.extract_serial_numbers(
data['serial_numbers'],
data['quantity'],
)
item.serializeStock(
data['quantity'],
serials,
user,
notes=data.get('notes', ''),
location=data['destination'],
)
class LocationSerializer(InvenTreeModelSerializer): class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Detailed information about a stock location """ Detailed information about a stock location
""" """
@ -278,7 +393,7 @@ class LocationSerializer(InvenTreeModelSerializer):
] ]
class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
""" Serializer for StockItemAttachment model """ """ Serializer for StockItemAttachment model """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -289,9 +404,9 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
if user_detail is not True: if user_detail is not True:
self.fields.pop('user_detail') self.fields.pop('user_detail')
user_detail = UserSerializerBrief(source='user', read_only=True) user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True)
attachment = InvenTreeAttachmentSerializerField(required=True) attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True)
# TODO: Record the uploading user when creating or updating an attachment! # TODO: Record the uploading user when creating or updating an attachment!
@ -316,14 +431,14 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
] ]
class StockItemTestResultSerializer(InvenTreeModelSerializer): class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Serializer for the StockItemTestResult model """ """ Serializer for the StockItemTestResult model """
user_detail = UserSerializerBrief(source='user', read_only=True) user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True)
key = serializers.CharField(read_only=True) key = serializers.CharField(read_only=True)
attachment = InvenTreeAttachmentSerializerField(required=False) attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
user_detail = kwargs.pop('user_detail', False) user_detail = kwargs.pop('user_detail', False)
@ -357,7 +472,7 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer):
] ]
class StockTrackingSerializer(InvenTreeModelSerializer): class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Serializer for StockItemTracking model """ """ Serializer for StockItemTracking model """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -377,7 +492,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
user_detail = UserSerializerBrief(source='user', many=False, read_only=True) user_detail = InvenTree.serializers.UserSerializerBrief(source='user', many=False, read_only=True)
deltas = serializers.JSONField(read_only=True) deltas = serializers.JSONField(read_only=True)

View File

@ -418,12 +418,18 @@
{{ block.super }} {{ block.super }}
$("#stock-serialize").click(function() { $("#stock-serialize").click(function() {
launchModalForm(
"{% url 'stock-item-serialize' item.id %}", serializeStockItem({{ item.pk }}, {
{ reload: true,
reload: true, data: {
quantity: {{ item.quantity }},
{% if item.location %}
destination: {{ item.location.pk }},
{% elif item.part.default_location %}
destination: {{ item.part.default_location.pk }},
{% endif %}
} }
); });
}); });
$('#stock-install-in').click(function() { $('#stock-install-in').click(function() {

View File

@ -126,46 +126,6 @@ class StockItemTest(StockViewTestCase):
self.assertIn(expected, str(response.content)) self.assertIn(expected, str(response.content))
def test_serialize_item(self):
# Test the serialization view
url = reverse('stock-item-serialize', args=(100,))
# GET the form
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data_valid = {
'quantity': 5,
'serial_numbers': '1-5',
'destination': 4,
'notes': 'Serializing stock test'
}
data_invalid = {
'quantity': 4,
'serial_numbers': 'dd-23-adf',
'destination': 'blorg'
}
# POST
response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['form_valid'])
# Try again to serialize with the same numbers
response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
# POST with invalid data
response = self.client.post(url, data_invalid, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
class StockOwnershipTest(StockViewTestCase): class StockOwnershipTest(StockViewTestCase):
""" Tests for stock ownership views """ """ Tests for stock ownership views """

View File

@ -20,7 +20,6 @@ location_urls = [
stock_item_detail_urls = [ stock_item_detail_urls = [
url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),

View File

@ -1027,89 +1027,6 @@ class StockLocationCreate(AjaxCreateView):
pass pass
class StockItemSerialize(AjaxUpdateView):
""" View for manually serializing a StockItem """
model = StockItem
ajax_template_name = 'stock/item_serialize.html'
ajax_form_title = _('Serialize Stock')
form_class = StockForms.SerializeStockForm
def get_form(self):
context = self.get_form_kwargs()
# Pass the StockItem object through to the form
context['item'] = self.get_object()
form = StockForms.SerializeStockForm(**context)
return form
def get_initial(self):
initials = super().get_initial().copy()
item = self.get_object()
initials['quantity'] = item.quantity
initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
if item.location is not None:
initials['destination'] = item.location.pk
return initials
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
form = self.get_form()
item = self.get_object()
quantity = request.POST.get('quantity', 0)
serials = request.POST.get('serial_numbers', '')
dest_id = request.POST.get('destination', None)
notes = request.POST.get('note', '')
user = request.user
valid = True
try:
destination = StockLocation.objects.get(pk=dest_id)
except (ValueError, StockLocation.DoesNotExist):
destination = None
try:
numbers = extract_serial_numbers(serials, quantity)
except ValidationError as e:
form.add_error('serial_numbers', e.messages)
valid = False
numbers = []
if valid:
try:
item.serializeStock(quantity, numbers, user, notes=notes, location=destination)
except ValidationError as e:
messages = e.message_dict
for k in messages.keys():
if k in ['quantity', 'destination', 'serial_numbers']:
form.add_error(k, messages[k])
else:
form.add_error(None, messages[k])
valid = False
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data=data)
class StockItemCreate(AjaxCreateView): class StockItemCreate(AjaxCreateView):
""" """
View for creating a new StockItem View for creating a new StockItem

View File

@ -52,12 +52,39 @@
loadStockTrackingTable, loadStockTrackingTable,
loadTableFilters, loadTableFilters,
removeStockRow, removeStockRow,
serializeStockItem,
stockItemFields, stockItemFields,
stockLocationFields, stockLocationFields,
stockStatusCodes, stockStatusCodes,
*/ */
/*
* Launches a modal form to serialize a particular StockItem
*/
function serializeStockItem(pk, options={}) {
var url = `/api/stock/${pk}/serialize/`;
options.method = 'POST';
options.title = '{% trans "Serialize Stock Item" %}';
options.fields = {
quantity: {},
serial_numbers: {
icon: 'fa-hashtag',
},
destination: {
icon: 'fa-sitemap',
},
notes: {},
}
constructForm(url, options);
}
function stockLocationFields(options={}) { function stockLocationFields(options={}) {
var fields = { var fields = {
parent: { parent: {