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()
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):
"""
A generic class for handling stocktake actions.
@ -1126,8 +1147,11 @@ stock_api_urls = [
url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
])),
# Detail for a single stock item
url(r'^(?P<pk>\d+)/', StockDetail.as_view(), name='api-stock-detail'),
# Detail views for a single stock item
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
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 django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import ugettext_lazy as _
from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value
@ -27,14 +28,15 @@ from .models import StockItemTestResult
import common.models
from common.settings import currency_code_default, currency_code_mappings
from company.serializers import SupplierPartSerializer
import InvenTree.helpers
import InvenTree.serializers
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
"""
@ -48,7 +50,7 @@ class LocationBriefSerializer(InvenTreeModelSerializer):
]
class StockItemSerializerBrief(InvenTreeModelSerializer):
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
""" Brief serializers for a StockItem """
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:
- 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)
purchase_price = InvenTreeMoneySerializer(
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
label=_('Purchase Price'),
max_digits=19, decimal_places=4,
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:
model = StockItem
fields = ('quantity',)
fields = [
'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
"""
@ -278,7 +393,7 @@ class LocationSerializer(InvenTreeModelSerializer):
]
class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
""" Serializer for StockItemAttachment model """
def __init__(self, *args, **kwargs):
@ -289,9 +404,9 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
if user_detail is not True:
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!
@ -316,14 +431,14 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
]
class StockItemTestResultSerializer(InvenTreeModelSerializer):
class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" 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)
attachment = InvenTreeAttachmentSerializerField(required=False)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False)
def __init__(self, *args, **kwargs):
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 """
def __init__(self, *args, **kwargs):
@ -377,7 +492,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
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)

View File

@ -418,12 +418,18 @@
{{ block.super }}
$("#stock-serialize").click(function() {
launchModalForm(
"{% url 'stock-item-serialize' item.id %}",
{
reload: true,
serializeStockItem({{ item.pk }}, {
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() {

View File

@ -126,46 +126,6 @@ class StockItemTest(StockViewTestCase):
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):
""" Tests for stock ownership views """

View File

@ -20,7 +20,6 @@ location_urls = [
stock_item_detail_urls = [
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'^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'),

View File

@ -1027,89 +1027,6 @@ class StockLocationCreate(AjaxCreateView):
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):
"""
View for creating a new StockItem

View File

@ -52,12 +52,39 @@
loadStockTrackingTable,
loadTableFilters,
removeStockRow,
serializeStockItem,
stockItemFields,
stockLocationFields,
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={}) {
var fields = {
parent: {