diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index eb6d42cc6d..0c0efea64d 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -5,10 +5,15 @@ JSON API for the Build app # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.conf.urls import url, include +from django.utils.translation import ugettext_lazy as _ -from rest_framework import filters -from rest_framework import generics +from django.db import transaction +from django.conf.urls import url, include +from django.core.exceptions import ValidationError as DjangoValidationError + +from rest_framework import filters, generics, serializers, status +from rest_framework.serializers import ValidationError +from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as rest_filters @@ -19,6 +24,7 @@ from InvenTree.status_codes import BuildStatus from .models import Build, BuildItem, BuildOrderAttachment from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer +from .serializers import BuildAllocationSerializer class BuildFilter(rest_filters.FilterSet): @@ -181,6 +187,100 @@ class BuildDetail(generics.RetrieveUpdateAPIView): serializer_class = BuildSerializer +class BuildAllocate(generics.CreateAPIView): + """ + API endpoint to allocate stock items to a build order + + - The BuildOrder object is specified by the URL + - Items to allocate are specified as a list called "items" with the following options: + - bom_item: pk value of a given BomItem object (must match the part associated with this build) + - stock_item: pk value of a given StockItem object + - quantity: quantity to allocate + - output: StockItem (build order output) to allocate stock against (optional) + """ + + queryset = Build.objects.none() + + serializer_class = BuildAllocationSerializer + + def get_build(self): + """ + Returns the BuildOrder associated with this API endpoint + """ + + pk = self.kwargs.get('pk', None) + + try: + build = Build.objects.get(pk=pk) + except (Build.DoesNotExist, ValueError): + raise ValidationError(_("Matching build order does not exist")) + + return build + + def get_serializer_context(self): + + context = super().get_serializer_context() + + context['build'] = self.get_build() + + return context + + def create(self, request, *args, **kwargs): + + # Which build are we receiving against? + build = self.get_build() + + # Validate the serialized data + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Allocate the stock items + try: + self.allocate_items(build, serializer) + except DjangoValidationError as exc: + # Re-throw a django error as a DRF error + raise ValidationError(detail=serializers.as_serializer_error(exc)) + + headers = self.get_success_headers(serializer.data) + + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + @transaction.atomic + def allocate_items(self, build, serializer): + """ + Allocate the provided stock items to this order. + + At this point, most of the heavy lifting has been done for us by the DRF serializer. + + We have a list of "items" each a dict containing: + + - bom_item: A validated BomItem object which matches this build + - stock_item: A validated StockItem object which matches the bom_item + - quantity: A validated numerical quantity which does not exceed the available stock + - output: A validated StockItem object to assign stock against (optional) + + """ + + data = serializer.validated_data + + items = data.get('items', []) + + for item in items: + + bom_item = item['bom_item'] + stock_item = item['stock_item'] + quantity = item['quantity'] + output = item.get('output', None) + + # Create a new BuildItem to allocate stock + build_item = BuildItem.objects.create( + build=build, + stock_item=stock_item, + quantity=quantity, + install_into=output + ) + + class BuildItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of BuildItem objects @@ -291,7 +391,10 @@ build_api_urls = [ ])), # Build Detail - url(r'^(?P\d+)/', BuildDetail.as_view(), name='api-build-detail'), + url(r'^(?P\d+)/', include([ + url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), + ])), # Build List url(r'^.*$', BuildList.as_view(), name='api-build-list'), diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 69e3a7aed0..f29cc1de2c 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -5,16 +5,21 @@ JSON serializers for Build API # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.utils.translation import ugettext_lazy as _ + from django.db.models import Case, When, Value from django.db.models import BooleanField from rest_framework import serializers +from rest_framework.serializers import ValidationError from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief -from stock.serializers import StockItemSerializerBrief -from stock.serializers import LocationSerializer +from stock.models import StockItem +from stock.serializers import StockItemSerializerBrief, LocationSerializer + +from part.models import Part, BomItem from part.serializers import PartSerializer, PartBriefSerializer from users.serializers import OwnerSerializer @@ -22,7 +27,9 @@ from .models import Build, BuildItem, BuildOrderAttachment class BuildSerializer(InvenTreeModelSerializer): - """ Serializes a Build object """ + """ + Serializes a Build object + """ url = serializers.CharField(source='get_absolute_url', read_only=True) status_text = serializers.CharField(source='get_status_display', read_only=True) @@ -109,6 +116,124 @@ class BuildSerializer(InvenTreeModelSerializer): ] +class BuildAllocationItemSerializer(serializers.Serializer): + """ + A serializer for allocating a single stock item against a build order + """ + + bom_item = serializers.PrimaryKeyRelatedField( + queryset=BomItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('BOM Item'), + ) + + def validate_bom_item(self, bom_item): + + build = self.context['build'] + + # BomItem must point to the same 'part' as the parent build + if build.part != bom_item.part: + raise ValidationError(_("bom_item.part must point to the same part as the build order")) + + return bom_item + + stock_item = serializers.PrimaryKeyRelatedField( + queryset=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 + ) + + output = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.filter(is_building=True), + many=False, + allow_null=True, + required=False, + label=_('Build Output'), + ) + + class Meta: + fields = [ + 'bom_item', + 'stock_item', + 'quantity', + 'output', + ] + + def is_valid(self, raise_exception=False): + + if super().is_valid(raise_exception): + + data = self.validated_data + + bom_item = data['bom_item'] + stock_item = data['stock_item'] + quantity = data['quantity'] + output = data.get('output', None) + + build = self.context['build'] + + # TODO: Check that the "stock item" is valid for the referenced "sub_part" + # Note: Because of allow_variants options, it may not be a direct match! + + # TODO: Check that the quantity does not exceed the available amount from the stock item + + # Output *must* be set for trackable parts + if output is None and bom_item.sub_part.trackable: + self._errors['output'] = _('Build output must be specified for allocation of tracked parts') + + # Output *cannot* be set for un-tracked parts + if output is not None and not bom_item.sub_part.trackable: + self._errors['output'] = _('Build output cannot be specified for allocation of untracked parts') + + if self._errors and raise_exception: + raise ValidationError(self.errors) + + return not bool(self._errors) + + +class BuildAllocationSerializer(serializers.Serializer): + """ + DRF serializer for allocation stock items against a build order + """ + + items = BuildAllocationItemSerializer(many=True) + + class Meta: + fields = [ + 'items', + ] + + def is_valid(self, raise_exception=False): + """ + Validation + """ + + super().is_valid(raise_exception) + + data = self.validated_data + + items = data.get('items', []) + + if len(items) == 0: + self._errors['items'] = _('Allocation items must be provided') + + if self._errors and raise_exception: + raise ValidationError(self.errors) + + return not bool(self._errors) + + class BuildItemSerializer(InvenTreeModelSerializer): """ Serializes a BuildItem object """