2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-03 05:48:47 +00:00
InvenTree/InvenTree/order/serializers.py
Oliver 906ae218aa
[Plugin] Allow custom plugins for running validation routines (#3776)
* Adds new plugin mixin for performing custom validation steps

* Adds simple test plugin for custom validation

* Run part name and IPN validators checks through loaded plugins

* Expose more validation functions to plugins:

- SalesOrder reference
- PurchaseOrder reference
- BuildOrder reference

* Remove custom validation of reference fields

- For now, this is too complex to consider given the current incrementing-reference implementation
- Might revisit this at a later stage.

* Custom validation of serial numbers:

- Replace "checkIfSerialNumberExists" method with "validate_serial_number"
- Pass serial number through to custom plugins
- General code / docstring improvements

* Update unit tests

* Update InvenTree/stock/tests.py

Co-authored-by: Matthias Mair <code@mjmair.com>

* Adds global setting to specify whether serial numbers must be unique globally

- Default is false to preserve behaviour

* Improved error message when attempting to create stock item with invalid serial numbers

* Add more detail to existing serial error message

* Add unit testing for serial number uniqueness

* Allow plugins to convert a serial number to an integer (for optimized sorting)

* Add placeholder plugin methods for incrementing and decrementing serial numbers

* Typo fix

* Add improved method for determining the "latest" serial number

* Remove calls to getLatestSerialNumber

* Update validate_serial_number method

- Add option to disable checking for duplicates
- Don't pass optional StockItem through to plugins

* Refactor serial number extraction methods

- Expose the "incrementing" portion to external plugins

* Bug fixes

* Update unit tests

* Fix for get_latest_serial_number

* Ensure custom serial integer values are clipped

* Adds a plugin for validating and generating hexadecimal serial numbers

* Update unit tests

* Add stub methods for batch code functionality

* remove "hex serials" plugin

- Was simply breaking unit tests

* Allow custom plugins to generate and validate batch codes

- Perform batch code validation when StockItem is saved
- Improve display of error message in modal forms

* Fix unit tests for stock app

* Log message if plugin has a duplicate slug

* Unit test fix

Co-authored-by: Matthias Mair <code@mjmair.com>
2022-10-18 23:50:07 +11:00

1441 lines
42 KiB
Python

"""JSON serializers for the Order API."""
from datetime import datetime
from decimal import Decimal
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models, transaction
from django.db.models import (BooleanField, Case, ExpressionWrapper, F, Q,
Value, When)
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount
import order.models
import part.filters
import stock.models
import stock.serializers
from common.settings import currency_code_mappings
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from InvenTree.helpers import extract_serial_numbers, normalize, str2bool
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer)
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
StockStatus)
from part.serializers import PartBriefSerializer
from users.serializers import OwnerSerializer
class AbstractOrderSerializer(serializers.Serializer):
"""Abstract field definitions for OrderSerializers."""
total_price = InvenTreeMoneySerializer(
source='get_total_price',
allow_null=True,
read_only=True,
)
total_price_string = serializers.CharField(source='get_total_price', read_only=True)
class AbstractExtraLineSerializer(serializers.Serializer):
"""Abstract Serializer for a ExtraLine object."""
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer"""
order_detail = kwargs.pop('order_detail', False)
super().__init__(*args, **kwargs)
if order_detail is not True:
self.fields.pop('order_detail')
quantity = serializers.FloatField()
price = InvenTreeMoneySerializer(
allow_null=True
)
price_string = serializers.CharField(source='price', read_only=True)
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
help_text=_('Price currency'),
)
class AbstractExtraLineMeta:
"""Abstract Meta for ExtraLine."""
fields = [
'pk',
'quantity',
'reference',
'notes',
'context',
'order',
'order_detail',
'price',
'price_currency',
'price_string',
]
class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializer for a PurchaseOrder object."""
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer"""
supplier_detail = kwargs.pop('supplier_detail', False)
super().__init__(*args, **kwargs)
if supplier_detail is not True:
self.fields.pop('supplier_detail')
@staticmethod
def annotate_queryset(queryset):
"""Add extra information to the queryset.
- Number of lines in the PurchaseOrder
- Overdue status of the PurchaseOrder
"""
queryset = queryset.annotate(
line_items=SubqueryCount('lines')
)
queryset = queryset.annotate(
overdue=Case(
When(
order.models.PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField())
)
)
return queryset
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
line_items = serializers.IntegerField(read_only=True)
status_text = serializers.CharField(source='get_status_display', read_only=True)
overdue = serializers.BooleanField(required=False, read_only=True)
reference = serializers.CharField(required=True)
def validate_reference(self, reference):
"""Custom validation for the reference field"""
# Ensure that the reference matches the required pattern
order.models.PurchaseOrder.validate_reference_field(reference)
return reference
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
class Meta:
"""Metaclass options."""
model = order.models.PurchaseOrder
fields = [
'pk',
'issue_date',
'complete_date',
'creation_date',
'description',
'line_items',
'link',
'overdue',
'reference',
'responsible',
'responsible_detail',
'supplier',
'supplier_detail',
'supplier_reference',
'status',
'status_text',
'target_date',
'notes',
'total_price',
'total_price_string',
]
read_only_fields = [
'status'
'issue_date',
'complete_date',
'creation_date',
]
class PurchaseOrderCancelSerializer(serializers.Serializer):
"""Serializer for cancelling a PurchaseOrder."""
class Meta:
"""Metaclass options."""
fields = [],
def get_context_data(self):
"""Return custom context information about the order."""
self.order = self.context['order']
return {
'can_cancel': self.order.can_cancel(),
}
def save(self):
"""Save the serializer to 'cancel' the order"""
order = self.context['order']
if not order.can_cancel():
raise ValidationError(_("Order cannot be cancelled"))
order.cancel_order()
class PurchaseOrderCompleteSerializer(serializers.Serializer):
"""Serializer for completing a purchase order."""
accept_incomplete = serializers.BooleanField(
label=_('Accept Incomplete'),
help_text=_('Allow order to be closed with incomplete line items'),
required=False,
default=False,
)
def validate_accept_incomplete(self, value):
"""Check if the 'accept_incomplete' field is required"""
order = self.context['order']
if not value and not order.is_complete:
raise ValidationError(_("Order has incomplete line items"))
return value
class Meta:
"""Metaclass options."""
fields = []
def get_context_data(self):
"""Custom context information for this serializer."""
order = self.context['order']
return {
'is_complete': order.is_complete,
}
def save(self):
"""Save the serializer to 'complete' the order"""
order = self.context['order']
order.complete_order()
class PurchaseOrderIssueSerializer(serializers.Serializer):
"""Serializer for issuing (sending) a purchase order."""
class Meta:
"""Metaclass options."""
fields = []
def save(self):
"""Save the serializer to 'place' the order"""
order = self.context['order']
order.place_order()
class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
"""Serializer class for the PurchaseOrderLineItem model"""
@staticmethod
def annotate_queryset(queryset):
"""Add some extra annotations to this queryset:
- Total price = purchase_price * quantity
- "Overdue" status (boolean field)
"""
queryset = queryset.annotate(
total_price=ExpressionWrapper(
F('purchase_price') * F('quantity'),
output_field=models.DecimalField()
)
)
queryset = queryset.annotate(
overdue=Case(
When(
Q(order__status__in=PurchaseOrderStatus.OPEN) & order.models.PurchaseOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField())
),
default=Value(False, output_field=BooleanField()),
)
)
return queryset
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer"""
part_detail = kwargs.pop('part_detail', False)
order_detail = kwargs.pop('order_detail', False)
super().__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
self.fields.pop('supplier_part_detail')
if order_detail is not True:
self.fields.pop('order_detail')
quantity = serializers.FloatField(min_value=0, required=True)
def validate_quantity(self, quantity):
"""Validation for the 'quantity' field"""
if quantity <= 0:
raise ValidationError(_("Quantity must be greater than zero"))
return quantity
def validate_purchase_order(self, purchase_order):
"""Validation for the 'purchase_order' field"""
if purchase_order.status not in PurchaseOrderStatus.OPEN:
raise ValidationError(_('Order is not open'))
return purchase_order
received = serializers.FloatField(default=0, read_only=True)
overdue = serializers.BooleanField(required=False, read_only=True)
total_price = serializers.FloatField(read_only=True)
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
purchase_price = InvenTreeMoneySerializer(
allow_null=True
)
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True)
purchase_price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
help_text=_('Purchase price currency'),
)
order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False)
def validate(self, data):
"""Custom validation for the serializer:
- Ensure the supplier_part field is supplied
- Ensure the purchase_order field is supplied
- Ensure that the supplier_part and supplier references match
"""
data = super().validate(data)
supplier_part = data.get('part', None)
purchase_order = data.get('order', None)
if not supplier_part:
raise ValidationError({
'part': _('Supplier part must be specified'),
})
if not purchase_order:
raise ValidationError({
'order': _('Purchase order must be specified'),
})
# Check that the supplier part and purchase order match
if supplier_part is not None and supplier_part.supplier != purchase_order.supplier:
raise ValidationError({
'part': _('Supplier must match purchase order'),
'order': _('Purchase order must match supplier'),
})
return data
class Meta:
"""Metaclass options."""
model = order.models.PurchaseOrderLineItem
fields = [
'pk',
'quantity',
'reference',
'notes',
'order',
'order_detail',
'overdue',
'part',
'part_detail',
'supplier_part_detail',
'received',
'purchase_price',
'purchase_price_currency',
'purchase_price_string',
'destination',
'destination_detail',
'target_date',
'total_price',
]
class PurchaseOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
"""Serializer for a PurchaseOrderExtraLine object."""
order_detail = PurchaseOrderSerializer(source='order', many=False, read_only=True)
class Meta(AbstractExtraLineMeta):
"""Metaclass options."""
model = order.models.PurchaseOrderExtraLine
class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
"""A serializer for receiving a single purchase order line item against a purchase order."""
class Meta:
"""Metaclass options."""
fields = [
'barcode',
'line_item',
'location',
'quantity',
'status',
'batch_code'
'serial_numbers',
]
line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.PurchaseOrderLineItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Line Item'),
)
def validate_line_item(self, item):
"""Validation for the 'line_item' field"""
if item.order != self.context['order']:
raise ValidationError(_('Line item does not match purchase order'))
return item
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('Location'),
help_text=_('Select destination location for received items'),
)
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
required=True,
)
def validate_quantity(self, quantity):
"""Validation for the 'quantity' field"""
if quantity <= 0:
raise ValidationError(_("Quantity must be greater than zero"))
return quantity
batch_code = serializers.CharField(
label=_('Batch Code'),
help_text=_('Enter batch code for incoming stock items'),
required=False,
default='',
allow_blank=True,
)
serial_numbers = serializers.CharField(
label=_('Serial Numbers'),
help_text=_('Enter serial numbers for incoming stock items'),
required=False,
default='',
allow_blank=True,
)
status = serializers.ChoiceField(
choices=list(StockStatus.items()),
default=StockStatus.OK,
label=_('Status'),
)
barcode = serializers.CharField(
label=_('Barcode Hash'),
help_text=_('Unique identifier field'),
default='',
required=False,
allow_null=True,
allow_blank=True,
)
def validate_barcode(self, barcode):
"""Cannot check in a LineItem with a barcode that is already assigned."""
# Ignore empty barcode values
if not barcode or barcode.strip() == '':
return None
if stock.models.StockItem.objects.filter(barcode_hash=barcode).exists():
raise ValidationError(_('Barcode is already in use'))
return barcode
def validate(self, data):
"""Custom validation for the serializer:
- Integer quantity must be provided for serialized stock
- Validate serial numbers (if provided)
"""
data = super().validate(data)
line_item = data['line_item']
quantity = data['quantity']
serial_numbers = data.get('serial_numbers', '').strip()
base_part = line_item.part.part
pack_size = line_item.part.pack_size
pack_quantity = pack_size * quantity
# Does the quantity need to be "integer" (for trackable parts?)
if base_part.trackable:
if Decimal(pack_quantity) != int(pack_quantity):
raise ValidationError({
'quantity': _('An integer quantity must be provided for trackable parts'),
})
# If serial numbers are provided
if serial_numbers:
try:
# Pass the serial numbers through to the parent serializer once validated
data['serials'] = extract_serial_numbers(
serial_numbers,
pack_quantity,
base_part.get_latest_serial_number()
)
except DjangoValidationError as e:
raise ValidationError({
'serial_numbers': e.messages,
})
return data
class PurchaseOrderReceiveSerializer(serializers.Serializer):
"""Serializer for receiving items against a purchase order."""
items = PurchaseOrderLineItemReceiveSerializer(many=True)
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
many=False,
allow_null=True,
label=_('Location'),
help_text=_('Select destination location for received items'),
)
def validate(self, data):
"""Custom validation for the serializer:
- Ensure line items are provided
- Check that a location is specified
"""
super().validate(data)
items = data.get('items', [])
location = data.get('location', None)
if len(items) == 0:
raise ValidationError(_('Line items must be provided'))
# Check if the location is not specified for any particular item
for item in items:
line = item['line_item']
if not item.get('location', None):
# If a global location is specified, use that
item['location'] = location
if not item['location']:
# The line item specifies a location?
item['location'] = line.get_destination()
if not item['location']:
raise ValidationError({
'location': _("Destination location must be specified"),
})
# Ensure barcodes are unique
unique_barcodes = set()
for item in items:
barcode = item.get('barcode', '')
if barcode:
if barcode in unique_barcodes:
raise ValidationError(_('Supplied barcode values must be unique'))
else:
unique_barcodes.add(barcode)
return data
def save(self):
"""Perform the actual database transaction to receive purchase order items."""
data = self.validated_data
request = self.context['request']
order = self.context['order']
items = data['items']
location = data.get('location', None)
# Now we can actually receive the items into stock
with transaction.atomic():
for item in items:
# Select location
loc = item.get('location', None) or item['line_item'].get_destination() or location
try:
order.receive_line_item(
item['line_item'],
loc,
item['quantity'],
request.user,
status=item['status'],
barcode=item.get('barcode', ''),
batch_code=item.get('batch_code', ''),
serials=item.get('serials', None),
)
except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors
raise ValidationError(detail=serializers.as_serializer_error(exc))
class Meta:
"""Metaclass options."""
fields = [
'items',
'location',
]
class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializers for the PurchaseOrderAttachment model."""
class Meta:
"""Metaclass options."""
model = order.models.PurchaseOrderAttachment
fields = [
'pk',
'order',
'attachment',
'link',
'filename',
'comment',
'upload_date',
'user',
'user_detail',
]
read_only_fields = [
'upload_date',
]
class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializers for the SalesOrder object."""
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer"""
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs)
if customer_detail is not True:
self.fields.pop('customer_detail')
@staticmethod
def annotate_queryset(queryset):
"""Add extra information to the queryset.
- Number of line items in the SalesOrder
- Overdue status of the SalesOrder
"""
queryset = queryset.annotate(
line_items=SubqueryCount('lines')
)
queryset = queryset.annotate(
overdue=Case(
When(
order.models.SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField())
)
)
return queryset
customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True)
line_items = serializers.IntegerField(read_only=True)
status_text = serializers.CharField(source='get_status_display', read_only=True)
overdue = serializers.BooleanField(required=False, read_only=True)
reference = serializers.CharField(required=True)
def validate_reference(self, reference):
"""Custom validation for the reference field"""
# Ensure that the reference matches the required pattern
order.models.SalesOrder.validate_reference_field(reference)
return reference
class Meta:
"""Metaclass options."""
model = order.models.SalesOrder
fields = [
'pk',
'creation_date',
'customer',
'customer_detail',
'customer_reference',
'description',
'line_items',
'link',
'notes',
'overdue',
'reference',
'responsible',
'status',
'status_text',
'shipment_date',
'target_date',
'total_price',
'total_price_string',
]
read_only_fields = [
'status',
'creation_date',
'shipment_date',
]
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
"""Serializer for the SalesOrderAllocation model.
This includes some fields from the related model objects.
"""
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True)
serial = serializers.CharField(source='get_serial', read_only=True)
quantity = serializers.FloatField(read_only=False)
location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True)
# Extra detail fields
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True)
location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True)
customer_detail = CompanyBriefSerializer(source='line.order.customer', many=False, read_only=True)
shipment_date = serializers.DateField(source='shipment.shipment_date', read_only=True)
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer"""
order_detail = kwargs.pop('order_detail', False)
part_detail = kwargs.pop('part_detail', True)
item_detail = kwargs.pop('item_detail', True)
location_detail = kwargs.pop('location_detail', False)
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs)
if not order_detail:
self.fields.pop('order_detail')
if not part_detail:
self.fields.pop('part_detail')
if not item_detail:
self.fields.pop('item_detail')
if not location_detail:
self.fields.pop('location_detail')
if not customer_detail:
self.fields.pop('customer_detail')
class Meta:
"""Metaclass options."""
model = order.models.SalesOrderAllocation
fields = [
'pk',
'line',
'customer_detail',
'serial',
'quantity',
'location',
'location_detail',
'item',
'item_detail',
'order',
'order_detail',
'part',
'part_detail',
'shipment',
'shipment_date',
]
class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
"""Serializer for a SalesOrderLineItem object."""
@staticmethod
def annotate_queryset(queryset):
"""Add some extra annotations to this queryset:
- "overdue" status (boolean field)
- "available_quantity"
"""
queryset = queryset.annotate(
overdue=Case(
When(
Q(order__status__in=SalesOrderStatus.OPEN) & order.models.SalesOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField()),
)
)
# Annotate each line with the available stock quantity
# To do this, we need to look at the total stock and any allocations
queryset = queryset.alias(
total_stock=part.filters.annotate_total_stock(reference='part__'),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference='part__'),
allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference='part__'),
)
queryset = queryset.annotate(
available_stock=ExpressionWrapper(
F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
output_field=models.DecimalField()
)
)
return queryset
def __init__(self, *args, **kwargs):
"""Initializion routine for the serializer:
- Add extra related serializer information if required
"""
part_detail = kwargs.pop('part_detail', False)
order_detail = kwargs.pop('order_detail', False)
allocations = kwargs.pop('allocations', False)
super().__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
if order_detail is not True:
self.fields.pop('order_detail')
if allocations is not True:
self.fields.pop('allocations')
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
# Annotated fields
overdue = serializers.BooleanField(required=False, read_only=True)
available_stock = serializers.FloatField(read_only=True)
quantity = InvenTreeDecimalField()
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
shipped = InvenTreeDecimalField(read_only=True)
sale_price = InvenTreeMoneySerializer(
allow_null=True
)
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
sale_price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
help_text=_('Sale price currency'),
)
class Meta:
"""Metaclass options."""
model = order.models.SalesOrderLineItem
fields = [
'pk',
'allocated',
'allocations',
'available_stock',
'quantity',
'reference',
'notes',
'order',
'order_detail',
'overdue',
'part',
'part_detail',
'sale_price',
'sale_price_currency',
'sale_price_string',
'shipped',
'target_date',
]
class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
"""Serializer for the SalesOrderShipment class."""
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
order_detail = SalesOrderSerializer(source='order', read_only=True, many=False)
class Meta:
"""Metaclass options."""
model = order.models.SalesOrderShipment
fields = [
'pk',
'order',
'order_detail',
'allocations',
'shipment_date',
'checked_by',
'reference',
'tracking_number',
'invoice_number',
'link',
'notes',
]
class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
"""Serializer for completing (shipping) a SalesOrderShipment."""
class Meta:
"""Metaclass options."""
model = order.models.SalesOrderShipment
fields = [
'shipment_date',
'tracking_number',
'invoice_number',
'link',
]
def validate(self, data):
"""Custom validation for the serializer:
- Ensure the shipment reference is provided
"""
data = super().validate(data)
shipment = self.context.get('shipment', None)
if not shipment:
raise ValidationError(_("No shipment details provided"))
shipment.check_can_complete(raise_error=True)
return data
def save(self):
"""Save the serializer to complete the SalesOrderShipment"""
shipment = self.context.get('shipment', None)
if not shipment:
return
data = self.validated_data
request = self.context['request']
user = request.user
# Extract shipping date (defaults to today's date)
shipment_date = data.get('shipment_date', datetime.now())
if shipment_date is None:
# Shipment date should not be None - check above only
# checks if shipment_date exists in data
shipment_date = datetime.now()
shipment.complete_shipment(
user,
tracking_number=data.get('tracking_number', shipment.tracking_number),
invoice_number=data.get('invoice_number', shipment.invoice_number),
link=data.get('link', shipment.link),
shipment_date=shipment_date,
)
class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
"""A serializer for allocating a single stock-item against a SalesOrder shipment."""
class Meta:
"""Metaclass options."""
fields = [
'line_item',
'stock_item',
'quantity',
]
line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderLineItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Stock Item'),
)
def validate_line_item(self, line_item):
"""Custom validation for the 'line_item' field:
- Ensure the line_item is associated with the particular SalesOrder
"""
order = self.context['order']
# Ensure that the line item points to the correct order
if line_item.order != order:
raise ValidationError(_("Line item is not associated with this order"))
return line_item
stock_item = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Stock Item'),
)
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
required=True
)
def validate_quantity(self, quantity):
"""Custom validation for the 'quantity' field"""
if quantity <= 0:
raise ValidationError(_("Quantity must be positive"))
return quantity
def validate(self, data):
"""Custom validation for the serializer:
- Ensure that the quantity is 1 for serialized stock
- Quantity cannot exceed the available amount
"""
data = super().validate(data)
stock_item = data['stock_item']
quantity = data['quantity']
if stock_item.serialized and quantity != 1:
raise ValidationError({
'quantity': _("Quantity must be 1 for serialized stock item"),
})
q = normalize(stock_item.unallocated_quantity())
if quantity > q:
raise ValidationError({
'quantity': _(f"Available quantity ({q}) exceeded")
})
return data
class SalesOrderCompleteSerializer(serializers.Serializer):
"""DRF serializer for manually marking a sales order as complete."""
accept_incomplete = serializers.BooleanField(
label=_('Accept Incomplete'),
help_text=_('Allow order to be closed with incomplete line items'),
required=False,
default=False,
)
def validate_accept_incomplete(self, value):
"""Check if the 'accept_incomplete' field is required"""
order = self.context['order']
if not value and not order.is_completed():
raise ValidationError(_("Order has incomplete line items"))
return value
def get_context_data(self):
"""Custom context data for this serializer"""
order = self.context['order']
return {
'is_complete': order.is_completed(),
'pending_shipments': order.pending_shipment_count,
}
def validate(self, data):
"""Custom validation for the serializer"""
data = super().validate(data)
order = self.context['order']
order.can_complete(
raise_error=True,
allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)),
)
return data
def save(self):
"""Save the serializer to complete the SalesOrder"""
request = self.context['request']
order = self.context['order']
data = self.validated_data
user = getattr(request, 'user', None)
order.complete_order(
user,
allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)),
)
class SalesOrderCancelSerializer(serializers.Serializer):
"""Serializer for marking a SalesOrder as cancelled."""
def get_context_data(self):
"""Add extra context data to the serializer"""
order = self.context['order']
return {
'can_cancel': order.can_cancel(),
}
def save(self):
"""Save the serializer to cancel the order"""
order = self.context['order']
order.cancel_order()
class SalesOrderSerialAllocationSerializer(serializers.Serializer):
"""DRF serializer for allocation of serial numbers against a sales order / shipment."""
class Meta:
"""Metaclass options."""
fields = [
'line_item',
'quantity',
'serial_numbers',
'shipment',
]
line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderLineItem.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Line Item'),
)
def validate_line_item(self, line_item):
"""Ensure that the line_item is valid."""
order = self.context['order']
# Ensure that the line item points to the correct order
if line_item.order != order:
raise ValidationError(_("Line item is not associated with this order"))
return line_item
quantity = serializers.IntegerField(
min_value=1,
required=True,
allow_null=False,
label=_('Quantity'),
)
serial_numbers = serializers.CharField(
label=_("Serial Numbers"),
help_text=_("Enter serial numbers to allocate"),
required=True,
allow_blank=False,
)
shipment = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderShipment.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Shipment'),
)
def validate_shipment(self, shipment):
"""Validate the shipment:
- Must point to the same order
- Must not be shipped
"""
order = self.context['order']
if shipment.shipment_date is not None:
raise ValidationError(_("Shipment has already been shipped"))
if shipment.order != order:
raise ValidationError(_("Shipment is not associated with this order"))
return shipment
def validate(self, data):
"""Validation for the serializer:
- Ensure the serial_numbers and quantity fields match
- Check that all serial numbers exist
- Check that the serial numbers are not yet allocated
"""
data = super().validate(data)
line_item = data['line_item']
quantity = data['quantity']
serial_numbers = data['serial_numbers']
part = line_item.part
try:
data['serials'] = extract_serial_numbers(
serial_numbers,
quantity,
part.get_latest_serial_number()
)
except DjangoValidationError as e:
raise ValidationError({
'serial_numbers': e.messages,
})
serials_not_exist = []
serials_allocated = []
stock_items_to_allocate = []
for serial in data['serials']:
items = stock.models.StockItem.objects.filter(
part=part,
serial=serial,
quantity=1,
)
if not items.exists():
serials_not_exist.append(str(serial))
continue
stock_item = items[0]
if stock_item.unallocated_quantity() == 1:
stock_items_to_allocate.append(stock_item)
else:
serials_allocated.append(str(serial))
if len(serials_not_exist) > 0:
error_msg = _("No match found for the following serial numbers")
error_msg += ": "
error_msg += ",".join(serials_not_exist)
raise ValidationError({
'serial_numbers': error_msg
})
if len(serials_allocated) > 0:
error_msg = _("The following serial numbers are already allocated")
error_msg += ": "
error_msg += ",".join(serials_allocated)
raise ValidationError({
'serial_numbers': error_msg,
})
data['stock_items'] = stock_items_to_allocate
return data
def save(self):
"""Allocate stock items against the sales order"""
data = self.validated_data
line_item = data['line_item']
stock_items = data['stock_items']
shipment = data['shipment']
with transaction.atomic():
for stock_item in stock_items:
# Create a new SalesOrderAllocation
order.models.SalesOrderAllocation.objects.create(
line=line_item,
item=stock_item,
quantity=1,
shipment=shipment
)
class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
"""DRF serializer for allocation of stock items against a sales order / shipment."""
class Meta:
"""Metaclass options."""
fields = [
'items',
'shipment',
]
items = SalesOrderShipmentAllocationItemSerializer(many=True)
shipment = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderShipment.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Shipment'),
)
def validate_shipment(self, shipment):
"""Run validation against the provided shipment instance."""
order = self.context['order']
if shipment.shipment_date is not None:
raise ValidationError(_("Shipment has already been shipped"))
if shipment.order != order:
raise ValidationError(_("Shipment is not associated with this order"))
return shipment
def validate(self, data):
"""Serializer validation."""
data = super().validate(data)
# Extract SalesOrder from serializer context
# order = self.context['order']
items = data.get('items', [])
if len(items) == 0:
raise ValidationError(_('Allocation items must be provided'))
return data
def save(self):
"""Perform the allocation of items against this order."""
data = self.validated_data
items = data['items']
shipment = data['shipment']
with transaction.atomic():
for entry in items:
# Create a new SalesOrderAllocation
allocation = order.models.SalesOrderAllocation(
line=entry.get('line_item'),
item=entry.get('stock_item'),
quantity=entry.get('quantity'),
shipment=shipment,
)
allocation.full_clean()
allocation.save()
class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
"""Serializer for a SalesOrderExtraLine object."""
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
class Meta(AbstractExtraLineMeta):
"""Metaclass options."""
model = order.models.SalesOrderExtraLine
class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializers for the SalesOrderAttachment model."""
class Meta:
"""Metaclass options."""
model = order.models.SalesOrderAttachment
fields = [
'pk',
'order',
'attachment',
'filename',
'link',
'comment',
'upload_date',
'user',
'user_detail',
]
read_only_fields = [
'upload_date',
]