mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	[API] Return stock item list when creating multiple items (#9857)
* Return stock item information when serializing an existing item * Handle stock item creation * Commonize response * Provide build items in response * Formalize returned data type * Fix unit test
This commit is contained in:
		| @@ -1,12 +1,17 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 357 | ||||
| INVENTREE_API_VERSION = 358 | ||||
|  | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
|  | ||||
| v358 -> 2025-06-25 : https://github.com/inventree/InvenTree/pull/9857 | ||||
|     - Provide list of generated stock items against "StockItemSerialize" API endpoint | ||||
|     - Provide list of generated stock items against "StockList" API endpoint | ||||
|     - Provide list of generated stock items against "BuildOutputCreate" API endpoint | ||||
|  | ||||
| v357 -> 2025-06-25 : https://github.com/inventree/InvenTree/pull/9856 | ||||
|     - Adds "units" field to PluginSetting API endpoints | ||||
|  | ||||
|   | ||||
| @@ -8,13 +8,15 @@ from django.urls import include, path | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| from django_filters import rest_framework as rest_filters | ||||
| from drf_spectacular.utils import extend_schema_field | ||||
| from rest_framework import serializers | ||||
| from drf_spectacular.utils import extend_schema, extend_schema_field | ||||
| from rest_framework import serializers, status | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| import build.serializers | ||||
| import common.models | ||||
| import part.models as part_models | ||||
| import stock.serializers | ||||
| from build.models import Build, BuildItem, BuildLine | ||||
| from build.status_codes import BuildStatus, BuildStatusGroups | ||||
| from data_exporter.mixins import DataExportViewMixin | ||||
| @@ -647,6 +649,20 @@ class BuildOutputCreate(BuildOrderContextMixin, CreateAPI): | ||||
|  | ||||
|     serializer_class = build.serializers.BuildOutputCreateSerializer | ||||
|  | ||||
|     @extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)}) | ||||
|     def create(self, request, *args, **kwargs): | ||||
|         """Override the create method to handle the creation of build outputs.""" | ||||
|         serializer = self.get_serializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         # Create the build output(s) | ||||
|         outputs = serializer.save() | ||||
|  | ||||
|         response = stock.serializers.StockItemSerializer(outputs, many=True) | ||||
|  | ||||
|         # Return the created outputs | ||||
|         return Response(response.data, status=status.HTTP_201_CREATED) | ||||
|  | ||||
|  | ||||
| class BuildOutputScrap(BuildOrderContextMixin, CreateAPI): | ||||
|     """API endpoint for scrapping build output(s).""" | ||||
| @@ -705,7 +721,7 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI): | ||||
|     - Only looks at 'untracked' parts | ||||
|     - If stock exists in a single location, easy! | ||||
|     - If user decides that stock items are "fungible", allocate against multiple stock items | ||||
|     - If the user wants to, allocate substite parts if the primary parts are not available. | ||||
|     - If the user wants to, allocate substitute parts if the primary parts are not available. | ||||
|     """ | ||||
|  | ||||
|     queryset = Build.objects.none() | ||||
|   | ||||
| @@ -860,10 +860,10 @@ class Build( | ||||
|         allocations.delete() | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def create_build_output(self, quantity, **kwargs): | ||||
|     def create_build_output(self, quantity, **kwargs) -> list[stock.models.StockItem]: | ||||
|         """Create a new build output against this BuildOrder. | ||||
|  | ||||
|         Args: | ||||
|         Arguments: | ||||
|             quantity: The quantity of the item to produce | ||||
|  | ||||
|         Kwargs: | ||||
| @@ -871,6 +871,9 @@ class Build( | ||||
|             serials: Serial numbers | ||||
|             location: Override location | ||||
|             auto_allocate: Automatically allocate stock with matching serial numbers | ||||
|  | ||||
|         Returns: | ||||
|             A list of the created output (StockItem) objects. | ||||
|         """ | ||||
|         trackable_parts = self.part.get_trackable_parts() | ||||
|  | ||||
| @@ -895,6 +898,8 @@ class Build( | ||||
|                 'serials': _('Serial numbers must be provided for trackable parts') | ||||
|             }) | ||||
|  | ||||
|         outputs = [] | ||||
|  | ||||
|         # We are generating multiple serialized outputs | ||||
|         if serials: | ||||
|             """Create multiple build outputs with a single quantity of 1.""" | ||||
| @@ -989,10 +994,14 @@ class Build( | ||||
|                 }, | ||||
|             ) | ||||
|  | ||||
|             outputs = [output] | ||||
|  | ||||
|         if self.status == BuildStatus.PENDING: | ||||
|             self.status = BuildStatus.PRODUCTION.value | ||||
|             self.save() | ||||
|  | ||||
|         return outputs | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def delete_output(self, output): | ||||
|         """Remove a build output from the database. | ||||
|   | ||||
| @@ -421,7 +421,7 @@ class BuildOutputCreateSerializer(serializers.Serializer): | ||||
|         request = self.context.get('request') | ||||
|         build = self.get_build() | ||||
|  | ||||
|         build.create_build_output( | ||||
|         return build.create_build_output( | ||||
|             data['quantity'], | ||||
|             serials=self.serials, | ||||
|             batch=data.get('batch_code', ''), | ||||
|   | ||||
| @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| from django_filters import rest_framework as rest_filters | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import extend_schema_field | ||||
| from drf_spectacular.utils import extend_schema, extend_schema_field | ||||
| from rest_framework import status | ||||
| from rest_framework.generics import GenericAPIView | ||||
| from rest_framework.response import Response | ||||
| @@ -120,6 +120,21 @@ class StockItemSerialize(StockItemContextMixin, CreateAPI): | ||||
|  | ||||
|     serializer_class = StockSerializers.SerializeStockItemSerializer | ||||
|  | ||||
|     @extend_schema(responses={201: StockSerializers.StockItemSerializer(many=True)}) | ||||
|     def create(self, request, *args, **kwargs): | ||||
|         """Serialize the provided StockItem.""" | ||||
|         serializer = self.get_serializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         # Perform the actual serialization step | ||||
|         items = serializer.save() | ||||
|  | ||||
|         response = StockSerializers.StockItemSerializer( | ||||
|             items, many=True, context=self.get_serializer_context() | ||||
|         ) | ||||
|  | ||||
|         return Response(response.data, status=status.HTTP_201_CREATED) | ||||
|  | ||||
|  | ||||
| class StockItemInstall(StockItemContextMixin, CreateAPI): | ||||
|     """API endpoint for installing a particular stock item into this stock item. | ||||
| @@ -1010,6 +1025,10 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView): | ||||
|         # Check if a set of serial numbers was provided | ||||
|         serial_numbers = data.pop('serial_numbers', '') | ||||
|  | ||||
|         # Exclude 'serial' from submitted data | ||||
|         # We use 'serial_numbers' for item creation | ||||
|         data.pop('serial', None) | ||||
|  | ||||
|         # Check if the supplier_part has a package size defined, which is not 1 | ||||
|         if supplier_part_id := data.get('supplier_part', None): | ||||
|             try: | ||||
| @@ -1097,10 +1116,6 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView): | ||||
|         serializer = self.get_serializer(data=data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         # Exclude 'serial' from submitted data | ||||
|         # We use 'serial_numbers' for item creation | ||||
|         serializer.validated_data.pop('serial', None) | ||||
|  | ||||
|         # Extract location information | ||||
|         location = serializer.validated_data.get('location', None) | ||||
|  | ||||
| @@ -1127,7 +1142,11 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView): | ||||
|  | ||||
|                 StockItemTracking.objects.bulk_create(tracking) | ||||
|  | ||||
|                 response_data = {'quantity': quantity, 'serial_numbers': serials} | ||||
|                 response = StockSerializers.StockItemSerializer( | ||||
|                     items, many=True, context=self.get_serializer_context() | ||||
|                 ) | ||||
|  | ||||
|                 response_data = response.data | ||||
|  | ||||
|             else: | ||||
|                 # Create a single StockItem object | ||||
|   | ||||
| @@ -1699,23 +1699,33 @@ class StockItem( | ||||
|         return entry | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def serializeStock(self, quantity, serials, user, notes='', location=None): | ||||
|     def serializeStock( | ||||
|         self, | ||||
|         quantity: int, | ||||
|         serials: list[str], | ||||
|         user: User, | ||||
|         notes: str = '', | ||||
|         location: Optional[StockLocation] = None, | ||||
|     ): | ||||
|         """Split this stock item into unique serial numbers. | ||||
|  | ||||
|         - Quantity can be less than or equal to the quantity of the stock item | ||||
|         - Number of serial numbers must match the quantity | ||||
|         - Provided serial numbers must not already be in use | ||||
|  | ||||
|         Args: | ||||
|         Arguments: | ||||
|             quantity: Number of items to serialize (integer) | ||||
|             serials: List of serial numbers | ||||
|             user: User object associated with action | ||||
|             notes: Optional notes for tracking | ||||
|             location: If specified, serialized items will be placed in the given location | ||||
|  | ||||
|         Returns: | ||||
|             List of newly created StockItem objects, each with a unique serial number. | ||||
|         """ | ||||
|         # Cannot serialize stock that is already serialized! | ||||
|         if self.serialized: | ||||
|             return | ||||
|             return None | ||||
|  | ||||
|         if not self.part.trackable: | ||||
|             raise ValidationError({'part': _('Part is not set as trackable')}) | ||||
| @@ -1804,6 +1814,8 @@ class StockItem( | ||||
|             stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id, group='stock' | ||||
|         ) | ||||
|  | ||||
|         return items | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def copyHistoryFrom(self, other): | ||||
|         """Copy stock history from another StockItem.""" | ||||
|   | ||||
| @@ -790,8 +790,12 @@ class SerializeStockItemSerializer(serializers.Serializer): | ||||
|  | ||||
|         return data | ||||
|  | ||||
|     def save(self): | ||||
|         """Serialize stock item.""" | ||||
|     def save(self) -> list[StockItem]: | ||||
|         """Serialize the provided StockItem. | ||||
|  | ||||
|         Returns: | ||||
|             A list of StockItem objects that were created as a result of the serialization. | ||||
|         """ | ||||
|         item = self.context['item'] | ||||
|         request = self.context.get('request') | ||||
|         user = request.user if request else None | ||||
| @@ -805,12 +809,15 @@ class SerializeStockItemSerializer(serializers.Serializer): | ||||
|             part=item.part, | ||||
|         ) | ||||
|  | ||||
|         item.serializeStock( | ||||
|             data['quantity'], | ||||
|             serials, | ||||
|             user, | ||||
|             notes=data.get('notes', ''), | ||||
|             location=data['destination'], | ||||
|         return ( | ||||
|             item.serializeStock( | ||||
|                 data['quantity'], | ||||
|                 serials, | ||||
|                 user, | ||||
|                 notes=data.get('notes', ''), | ||||
|                 location=data['destination'], | ||||
|             ) | ||||
|             or [] | ||||
|         ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1142,7 +1142,7 @@ class CustomStockItemStatusTest(StockAPITestCase): | ||||
|         self.assertEqual(response.data['status'], self.status2.logical_key) | ||||
|         self.assertEqual(response.data['status_custom_key'], self.status2.key) | ||||
|  | ||||
|         # Try if status_custom_key is rewrite with status bying set | ||||
|         # Try with custom status code | ||||
|         response = self.patch( | ||||
|             reverse('api-stock-detail', kwargs={'pk': pk}), | ||||
|             {'status': self.status.logical_key}, | ||||
| @@ -1399,22 +1399,20 @@ class StockItemTest(StockAPITestCase): | ||||
|         ) | ||||
|  | ||||
|         data = response.data | ||||
|  | ||||
|         self.assertEqual(data['quantity'], 10) | ||||
|         sn = data['serial_numbers'] | ||||
|         self.assertEqual(len(data), 10) | ||||
|         serials = [item['serial'] for item in data] | ||||
|  | ||||
|         # Check that each serial number was created | ||||
|         for i in range(1, 11): | ||||
|             self.assertIn(str(i), sn) | ||||
|             self.assertIn(str(i), serials) | ||||
|  | ||||
|             # Check the unique stock item has been created | ||||
|  | ||||
|             item = StockItem.objects.get(part=trackable_part, serial=str(i)) | ||||
|         for item in data: | ||||
|             item = StockItem.objects.get(pk=item['pk']) | ||||
|  | ||||
|             # Item location should have been set automatically | ||||
|             self.assertIsNotNone(item.location) | ||||
|  | ||||
|             self.assertEqual(str(i), item.serial) | ||||
|             self.assertIn(item.serial, serials) | ||||
|  | ||||
|         # There now should be 10 unique stock entries for this part | ||||
|         self.assertEqual(trackable_part.stock_entries().count(), 10) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user