mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	Merge pull request #2013 from SchrodingersGat/receive-via-api
Receive via api
This commit is contained in:
		| @@ -10,11 +10,14 @@ import common.models | ||||
|  | ||||
| INVENTREE_SW_VERSION = "0.5.0 pre" | ||||
|  | ||||
| INVENTREE_API_VERSION = 11 | ||||
| INVENTREE_API_VERSION = 12 | ||||
|  | ||||
| """ | ||||
| Increment this API version number whenever there is a significant change to the API that any clients need to know about | ||||
|  | ||||
| v12 -> 2021-09-07 | ||||
|     - Adds API endpoint to receive stock items against a PurchaseOrder | ||||
|  | ||||
| v11 -> 2021-08-26 | ||||
|     - Adds "units" field to PartBriefSerializer | ||||
|     - This allows units to be introspected from the "part_detail" field in the StockItem serializer | ||||
|   | ||||
| @@ -5,12 +5,16 @@ JSON API for the Order app | ||||
| # -*- coding: utf-8 -*- | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.utils.translation import ugettext_lazy as _ | ||||
| from django.conf.urls import url, include | ||||
| from django.db import transaction | ||||
|  | ||||
| from django_filters import rest_framework as rest_filters | ||||
| from rest_framework import generics | ||||
| from rest_framework import filters, status | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ValidationError | ||||
|  | ||||
|  | ||||
| from InvenTree.filters import InvenTreeOrderingFilter | ||||
| from InvenTree.helpers import str2bool | ||||
| @@ -28,6 +32,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation | ||||
| from .models import SalesOrderAttachment | ||||
| from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer | ||||
| from .serializers import SalesOrderAllocationSerializer | ||||
| from .serializers import POReceiveSerializer | ||||
|  | ||||
|  | ||||
| class POList(generics.ListCreateAPIView): | ||||
| @@ -205,6 +210,111 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView): | ||||
|         return queryset | ||||
|  | ||||
|  | ||||
| class POReceive(generics.CreateAPIView): | ||||
|     """ | ||||
|     API endpoint to receive stock items against a purchase order. | ||||
|  | ||||
|     - The purchase order is specified in the URL. | ||||
|     - Items to receive are specified as a list called "items" with the following options: | ||||
|         - supplier_part: pk value of the supplier part | ||||
|         - quantity: quantity to receive | ||||
|         - status: stock item status | ||||
|         - location: destination for stock item (optional) | ||||
|     - A global location can also be specified | ||||
|     """ | ||||
|  | ||||
|     queryset = PurchaseOrderLineItem.objects.none() | ||||
|  | ||||
|     serializer_class = POReceiveSerializer | ||||
|  | ||||
|     def get_serializer_context(self): | ||||
|  | ||||
|         context = super().get_serializer_context() | ||||
|  | ||||
|         # Pass the purchase order through to the serializer for validation | ||||
|         context['order'] = self.get_order() | ||||
|  | ||||
|         return context | ||||
|  | ||||
|     def get_order(self): | ||||
|         """ | ||||
|         Returns the PurchaseOrder associated with this API endpoint | ||||
|         """ | ||||
|  | ||||
|         pk = self.kwargs.get('pk', None) | ||||
|  | ||||
|         if pk is None: | ||||
|             return None | ||||
|         else: | ||||
|             order = PurchaseOrder.objects.get(pk=self.kwargs['pk']) | ||||
|             return order | ||||
|  | ||||
|     def create(self, request, *args, **kwargs): | ||||
|  | ||||
|         # Which purchase order are we receiving against? | ||||
|         self.order = self.get_order() | ||||
|  | ||||
|         # Validate the serialized data | ||||
|         serializer = self.get_serializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         # Receive the line items | ||||
|         self.receive_items(serializer) | ||||
|  | ||||
|         headers = self.get_success_headers(serializer.data) | ||||
|         return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def receive_items(self, serializer): | ||||
|         """ | ||||
|         Receive the items | ||||
|  | ||||
|         At this point, much of the heavy lifting has been done for us by DRF serializers! | ||||
|  | ||||
|         We have a list of "items", each a dict which contains: | ||||
|         - line_item: A PurchaseOrderLineItem matching this order | ||||
|         - location: A destination location | ||||
|         - quantity: A validated numerical quantity | ||||
|         - status: The status code for the received item | ||||
|         """ | ||||
|  | ||||
|         data = serializer.validated_data | ||||
|  | ||||
|         location = data['location'] | ||||
|  | ||||
|         items = data['items'] | ||||
|  | ||||
|         # 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"), | ||||
|                 }) | ||||
|  | ||||
|         # Now we can actually receive the items | ||||
|         for item in items: | ||||
|  | ||||
|             self.order.receive_line_item( | ||||
|                 item['line_item'], | ||||
|                 item['location'], | ||||
|                 item['quantity'], | ||||
|                 self.request.user, | ||||
|                 status=item['status'], | ||||
|                 barcode=item.get('barcode', ''), | ||||
|             ) | ||||
|  | ||||
|  | ||||
| class POLineItemList(generics.ListCreateAPIView): | ||||
|     """ API endpoint for accessing a list of POLineItem objects | ||||
|  | ||||
| @@ -641,13 +751,25 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin) | ||||
|  | ||||
|  | ||||
| order_api_urls = [ | ||||
|  | ||||
|     # API endpoints for purchase orders | ||||
|     url(r'po/attachment/', include([ | ||||
|         url(r'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'), | ||||
|         url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'), | ||||
|     url(r'^po/', include([ | ||||
|  | ||||
|         # Purchase order attachments | ||||
|         url(r'attachment/', include([ | ||||
|             url(r'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'), | ||||
|             url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'), | ||||
|         ])), | ||||
|  | ||||
|         # Individual purchase order detail URLs | ||||
|         url(r'^(?P<pk>\d+)/', include([ | ||||
|             url(r'^receive/', POReceive.as_view(), name='api-po-receive'), | ||||
|             url(r'.*$', PODetail.as_view(), name='api-po-detail'), | ||||
|         ])), | ||||
|  | ||||
|         # Purchase order list | ||||
|         url(r'^.*$', POList.as_view(), name='api-po-list'), | ||||
|     ])), | ||||
|     url(r'^po/(?P<pk>\d+)/$', PODetail.as_view(), name='api-po-detail'), | ||||
|     url(r'^po/.*$', POList.as_view(), name='api-po-list'), | ||||
|  | ||||
|     # API endpoints for purchase order line items | ||||
|     url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), | ||||
|   | ||||
| @@ -411,6 +411,11 @@ class PurchaseOrder(Order): | ||||
|         """ | ||||
|  | ||||
|         notes = kwargs.get('notes', '') | ||||
|         barcode = kwargs.get('barcode', '') | ||||
|  | ||||
|         # Prevent null values for barcode | ||||
|         if barcode is None: | ||||
|             barcode = '' | ||||
|  | ||||
|         if not self.status == PurchaseOrderStatus.PLACED: | ||||
|             raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")}) | ||||
| @@ -433,7 +438,8 @@ class PurchaseOrder(Order): | ||||
|                 quantity=quantity, | ||||
|                 purchase_order=self, | ||||
|                 status=status, | ||||
|                 purchase_price=purchase_price, | ||||
|                 purchase_price=line.purchase_price, | ||||
|                 uid=barcode | ||||
|             ) | ||||
|  | ||||
|             stock.save(add_note=False) | ||||
|   | ||||
| @@ -12,6 +12,8 @@ from django.db.models import Case, When, Value | ||||
| from django.db.models import BooleanField, ExpressionWrapper, F | ||||
|  | ||||
| from rest_framework import serializers | ||||
| from rest_framework.serializers import ValidationError | ||||
|  | ||||
| from sql_util.utils import SubqueryCount | ||||
|  | ||||
| from InvenTree.serializers import InvenTreeModelSerializer | ||||
| @@ -19,8 +21,13 @@ from InvenTree.serializers import InvenTreeAttachmentSerializer | ||||
| from InvenTree.serializers import InvenTreeMoneySerializer | ||||
| from InvenTree.serializers import InvenTreeAttachmentSerializerField | ||||
|  | ||||
| from InvenTree.status_codes import StockStatus | ||||
|  | ||||
| from company.serializers import CompanyBriefSerializer, SupplierPartSerializer | ||||
|  | ||||
| from part.serializers import PartBriefSerializer | ||||
|  | ||||
| import stock.models | ||||
| from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer | ||||
|  | ||||
| from .models import PurchaseOrder, PurchaseOrderLineItem | ||||
| @@ -137,7 +144,6 @@ class POLineItemSerializer(InvenTreeModelSerializer): | ||||
|             self.fields.pop('part_detail') | ||||
|             self.fields.pop('supplier_part_detail') | ||||
|  | ||||
|     # TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values | ||||
|     quantity = serializers.FloatField(default=1) | ||||
|     received = serializers.FloatField(default=0) | ||||
|  | ||||
| @@ -182,6 +188,131 @@ class POLineItemSerializer(InvenTreeModelSerializer): | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class POLineItemReceiveSerializer(serializers.Serializer): | ||||
|     """ | ||||
|     A serializer for receiving a single purchase order line item against a purchase order | ||||
|     """ | ||||
|  | ||||
|     line_item = serializers.PrimaryKeyRelatedField( | ||||
|         queryset=PurchaseOrderLineItem.objects.all(), | ||||
|         many=False, | ||||
|         allow_null=False, | ||||
|         required=True, | ||||
|         label=_('Line Item'), | ||||
|     ) | ||||
|  | ||||
|     def validate_line_item(self, item): | ||||
|  | ||||
|         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, | ||||
|     ) | ||||
|  | ||||
|     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, | ||||
|     ) | ||||
|  | ||||
|     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 | ||||
|  | ||||
|         if stock.models.StockItem.objects.filter(uid=barcode).exists(): | ||||
|             raise ValidationError(_('Barcode is already in use')) | ||||
|  | ||||
|         return barcode | ||||
|  | ||||
|     class Meta: | ||||
|         fields = [ | ||||
|             'barcode', | ||||
|             'line_item', | ||||
|             'location', | ||||
|             'quantity', | ||||
|             'status', | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class POReceiveSerializer(serializers.Serializer): | ||||
|     """ | ||||
|     Serializer for receiving items against a purchase order | ||||
|     """ | ||||
|  | ||||
|     items = POLineItemReceiveSerializer(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 is_valid(self, raise_exception=False): | ||||
|  | ||||
|         super().is_valid(raise_exception) | ||||
|  | ||||
|         # Custom validation | ||||
|         data = self.validated_data | ||||
|  | ||||
|         items = data.get('items', []) | ||||
|  | ||||
|         if len(items) == 0: | ||||
|             self._errors['items'] = _('Line items must be provided') | ||||
|         else: | ||||
|             # Ensure barcodes are unique | ||||
|             unique_barcodes = set() | ||||
|  | ||||
|             for item in items: | ||||
|                 barcode = item.get('barcode', '') | ||||
|  | ||||
|                 if barcode: | ||||
|                     if barcode in unique_barcodes: | ||||
|                         self._errors['items'] = _('Supplied barcode values must be unique') | ||||
|                         break | ||||
|                     else: | ||||
|                         unique_barcodes.add(barcode) | ||||
|  | ||||
|         if self._errors and raise_exception: | ||||
|             raise ValidationError(self.errors) | ||||
|  | ||||
|         return not bool(self._errors) | ||||
|  | ||||
|     class Meta: | ||||
|         fields = [ | ||||
|             'items', | ||||
|             'location', | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class POAttachmentSerializer(InvenTreeAttachmentSerializer): | ||||
|     """ | ||||
|     Serializers for the PurchaseOrderAttachment model | ||||
|   | ||||
| @@ -9,8 +9,11 @@ from rest_framework import status | ||||
| from django.urls import reverse | ||||
|  | ||||
| from InvenTree.api_tester import InvenTreeAPITestCase | ||||
| from InvenTree.status_codes import PurchaseOrderStatus | ||||
|  | ||||
| from .models import PurchaseOrder, SalesOrder | ||||
| from stock.models import StockItem | ||||
|  | ||||
| from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder | ||||
|  | ||||
|  | ||||
| class OrderTest(InvenTreeAPITestCase): | ||||
| @@ -201,6 +204,250 @@ class PurchaseOrderTest(OrderTest): | ||||
|         response = self.get(url, expected_code=404) | ||||
|  | ||||
|  | ||||
| class PurchaseOrderReceiveTest(OrderTest): | ||||
|     """ | ||||
|     Unit tests for receiving items against a PurchaseOrder | ||||
|     """ | ||||
|  | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|          | ||||
|         self.assignRole('purchase_order.add') | ||||
|  | ||||
|         self.url = reverse('api-po-receive', kwargs={'pk': 1}) | ||||
|  | ||||
|         # Number of stock items which exist at the start of each test | ||||
|         self.n = StockItem.objects.count() | ||||
|  | ||||
|         # Mark the order as "placed" so we can receive line items | ||||
|         order = PurchaseOrder.objects.get(pk=1) | ||||
|         order.status = PurchaseOrderStatus.PLACED | ||||
|         order.save() | ||||
|  | ||||
|     def test_empty(self): | ||||
|         """ | ||||
|         Test without any POST data | ||||
|         """ | ||||
|  | ||||
|         data = self.post(self.url, {}, expected_code=400).data | ||||
|  | ||||
|         self.assertIn('This field is required', str(data['items'])) | ||||
|         self.assertIn('This field is required', str(data['location'])) | ||||
|  | ||||
|         # No new stock items have been created | ||||
|         self.assertEqual(self.n, StockItem.objects.count()) | ||||
|  | ||||
|     def test_no_items(self): | ||||
|         """ | ||||
|         Test with an empty list of items | ||||
|         """ | ||||
|  | ||||
|         data = self.post( | ||||
|             self.url, | ||||
|             { | ||||
|                 "items": [], | ||||
|                 "location": None, | ||||
|             }, | ||||
|             expected_code=400 | ||||
|         ).data | ||||
|  | ||||
|         self.assertIn('Line items must be provided', str(data['items'])) | ||||
|  | ||||
|         # No new stock items have been created | ||||
|         self.assertEqual(self.n, StockItem.objects.count()) | ||||
|  | ||||
|     def test_invalid_items(self): | ||||
|         """ | ||||
|         Test than errors are returned as expected for invalid data | ||||
|         """ | ||||
|  | ||||
|         data = self.post( | ||||
|             self.url, | ||||
|             { | ||||
|                 "items": [ | ||||
|                     { | ||||
|                         "line_item": 12345, | ||||
|                         "location": 12345 | ||||
|                     } | ||||
|                 ] | ||||
|             }, | ||||
|             expected_code=400 | ||||
|         ).data | ||||
|  | ||||
|         items = data['items'][0] | ||||
|  | ||||
|         self.assertIn('Invalid pk "12345"', str(items['line_item'])) | ||||
|         self.assertIn("object does not exist", str(items['location'])) | ||||
|  | ||||
|         # No new stock items have been created | ||||
|         self.assertEqual(self.n, StockItem.objects.count()) | ||||
|  | ||||
|     def test_invalid_status(self): | ||||
|         """ | ||||
|         Test with an invalid StockStatus value | ||||
|         """ | ||||
|  | ||||
|         data = self.post( | ||||
|             self.url, | ||||
|             { | ||||
|                 "items": [ | ||||
|                     { | ||||
|                         "line_item": 22, | ||||
|                         "location": 1, | ||||
|                         "status": 99999, | ||||
|                         "quantity": 5, | ||||
|                     } | ||||
|                 ] | ||||
|             }, | ||||
|             expected_code=400 | ||||
|         ).data | ||||
|  | ||||
|         self.assertIn('"99999" is not a valid choice.', str(data)) | ||||
|  | ||||
|         # No new stock items have been created | ||||
|         self.assertEqual(self.n, StockItem.objects.count()) | ||||
|  | ||||
|     def test_mismatched_items(self): | ||||
|         """ | ||||
|         Test for supplier parts which *do* exist but do not match the order supplier | ||||
|         """ | ||||
|  | ||||
|         data = self.post( | ||||
|             self.url, | ||||
|             { | ||||
|                 'items': [ | ||||
|                     { | ||||
|                         'line_item': 22, | ||||
|                         'quantity': 123, | ||||
|                         'location': 1, | ||||
|                     } | ||||
|                 ], | ||||
|                 'location': None, | ||||
|             }, | ||||
|             expected_code=400 | ||||
|         ).data | ||||
|  | ||||
|         self.assertIn('Line item does not match purchase order', str(data)) | ||||
|  | ||||
|         # No new stock items have been created | ||||
|         self.assertEqual(self.n, StockItem.objects.count()) | ||||
|  | ||||
|     def test_invalid_barcodes(self): | ||||
|         """ | ||||
|         Tests for checking in items with invalid barcodes: | ||||
|  | ||||
|         - Cannot check in "duplicate" barcodes | ||||
|         - Barcodes cannot match UID field for existing StockItem | ||||
|         """ | ||||
|  | ||||
|         # Set stock item barcode | ||||
|         item = StockItem.objects.get(pk=1) | ||||
|         item.uid = 'MY-BARCODE-HASH' | ||||
|         item.save() | ||||
|  | ||||
|         response = self.post( | ||||
|             self.url, | ||||
|             { | ||||
|                 'items': [ | ||||
|                     { | ||||
|                         'line_item': 1, | ||||
|                         'quantity': 50, | ||||
|                         'barcode': 'MY-BARCODE-HASH', | ||||
|                     } | ||||
|                 ], | ||||
|                 'location': 1, | ||||
|             }, | ||||
|             expected_code=400 | ||||
|         ) | ||||
|  | ||||
|         self.assertIn('Barcode is already in use', str(response.data)) | ||||
|  | ||||
|         response = self.post( | ||||
|             self.url, | ||||
|             { | ||||
|                 'items': [ | ||||
|                     { | ||||
|                         'line_item': 1, | ||||
|                         'quantity': 5, | ||||
|                         'barcode': 'MY-BARCODE-HASH-1', | ||||
|                     }, | ||||
|                     { | ||||
|                         'line_item': 1, | ||||
|                         'quantity': 5, | ||||
|                         'barcode': 'MY-BARCODE-HASH-1' | ||||
|                     }, | ||||
|                 ], | ||||
|                 'location': 1, | ||||
|             }, | ||||
|             expected_code=400 | ||||
|         ) | ||||
|  | ||||
|         self.assertIn('barcode values must be unique', str(response.data)) | ||||
|  | ||||
|         # No new stock items have been created | ||||
|         self.assertEqual(self.n, StockItem.objects.count()) | ||||
|  | ||||
|     def test_valid(self): | ||||
|         """ | ||||
|         Test receipt of valid data | ||||
|         """ | ||||
|  | ||||
|         line_1 = PurchaseOrderLineItem.objects.get(pk=1) | ||||
|         line_2 = PurchaseOrderLineItem.objects.get(pk=2) | ||||
|  | ||||
|         self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0) | ||||
|         self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0) | ||||
|  | ||||
|         self.assertEqual(line_1.received, 0) | ||||
|         self.assertEqual(line_2.received, 50) | ||||
|  | ||||
|         # Receive two separate line items against this order | ||||
|         self.post( | ||||
|             self.url, | ||||
|             { | ||||
|                 'items': [ | ||||
|                     { | ||||
|                         'line_item': 1, | ||||
|                         'quantity': 50, | ||||
|                         'barcode': 'MY-UNIQUE-BARCODE-123', | ||||
|                     }, | ||||
|                     { | ||||
|                         'line_item': 2, | ||||
|                         'quantity': 200, | ||||
|                         'location': 2,  # Explicit location | ||||
|                         'barcode': 'MY-UNIQUE-BARCODE-456', | ||||
|                     } | ||||
|                 ], | ||||
|                 'location': 1,  # Default location | ||||
|             }, | ||||
|             expected_code=201, | ||||
|         ) | ||||
|  | ||||
|         # There should be two newly created stock items | ||||
|         self.assertEqual(self.n + 2, StockItem.objects.count()) | ||||
|  | ||||
|         line_1 = PurchaseOrderLineItem.objects.get(pk=1) | ||||
|         line_2 = PurchaseOrderLineItem.objects.get(pk=2) | ||||
|  | ||||
|         self.assertEqual(line_1.received, 50) | ||||
|         self.assertEqual(line_2.received, 250) | ||||
|  | ||||
|         stock_1 = StockItem.objects.filter(supplier_part=line_1.part) | ||||
|         stock_2 = StockItem.objects.filter(supplier_part=line_2.part) | ||||
|  | ||||
|         # 1 new stock item created for each supplier part | ||||
|         self.assertEqual(stock_1.count(), 1) | ||||
|         self.assertEqual(stock_2.count(), 1) | ||||
|  | ||||
|         # Different location for each received item | ||||
|         self.assertEqual(stock_1.last().location.pk, 1) | ||||
|         self.assertEqual(stock_2.last().location.pk, 2) | ||||
|  | ||||
|         # Barcodes should have been assigned to the stock items | ||||
|         self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists()) | ||||
|         self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists()) | ||||
|  | ||||
|  | ||||
| class SalesOrderTest(OrderTest): | ||||
|     """ | ||||
|     Tests for the SalesOrder API | ||||
|   | ||||
| @@ -286,6 +286,8 @@ function constructForm(url, options) { | ||||
|         constructFormBody({}, options); | ||||
|     } | ||||
|  | ||||
|     options.fields = options.fields || {}; | ||||
|  | ||||
|     // Save the URL  | ||||
|     options.url = url; | ||||
|  | ||||
| @@ -545,6 +547,11 @@ function constructFormBody(fields, options) { | ||||
|  | ||||
|     initializeGroups(fields, options); | ||||
|  | ||||
|     if (options.afterRender) { | ||||
|         // Custom callback function after form rendering | ||||
|         options.afterRender(fields, options); | ||||
|     } | ||||
|  | ||||
|     // Scroll to the top | ||||
|     $(options.modal).find('.modal-form-content-wrapper').scrollTop(0); | ||||
| } | ||||
| @@ -1542,7 +1549,9 @@ function constructField(name, parameters, options) { | ||||
|     html += `<div id='div_${field_name}' class='${form_classes}'>`; | ||||
|  | ||||
|     // Add a label | ||||
|     html += constructLabel(name, parameters); | ||||
|     if (!options.hideLabels) { | ||||
|         html += constructLabel(name, parameters); | ||||
|     } | ||||
|  | ||||
|     html += `<div class='controls'>`; | ||||
|  | ||||
| @@ -1589,7 +1598,7 @@ function constructField(name, parameters, options) { | ||||
|         html += `</div>`; // input-group | ||||
|     } | ||||
|  | ||||
|     if (parameters.help_text) { | ||||
|     if (parameters.help_text && !options.hideLabels) { | ||||
|         html += constructHelpText(name, parameters, options); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -10,6 +10,7 @@ | ||||
|     makeProgressBar, | ||||
|     renderLink, | ||||
|     select2Thumbnail, | ||||
|     thumbnailImage | ||||
|     yesNoLabel, | ||||
| */ | ||||
|  | ||||
| @@ -56,6 +57,26 @@ function imageHoverIcon(url) { | ||||
| } | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * Renders a simple thumbnail image | ||||
|  * @param {String} url is the image URL  | ||||
|  * @returns html <img> tag | ||||
|  */ | ||||
| function thumbnailImage(url) { | ||||
|  | ||||
|     if (!url) { | ||||
|         url = '/static/img/blank_img.png'; | ||||
|     } | ||||
|  | ||||
|     // TODO: Support insertion of custom classes | ||||
|  | ||||
|     var html = `<img class='hover-img-thumb' src='${url}'>`; | ||||
|  | ||||
|     return html; | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| // Render a select2 thumbnail image | ||||
| function select2Thumbnail(image) { | ||||
|     if (!image) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user