diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 6d11d6a74e..f4b8c7740f 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 124 +INVENTREE_API_VERSION = 125 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v125 -> 2023-06-17 : https://github.com/inventree/InvenTree/pull/5064 + - Adds API endpoint for setting the "status" field for multiple stock items simultaneously + v124 -> 2023-06-17 : https://github.com/inventree/InvenTree/pull/5057 - Add "created_before" and "created_after" filters to the Part API diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 356e48feba..3dd9480ac1 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -158,6 +158,12 @@ class StockAdjustView(CreateAPI): return context +class StockChangeStatus(StockAdjustView): + """API endpoint to change the status code of multiple StockItem objects.""" + + serializer_class = StockSerializers.StockChangeStatusSerializer + + class StockCount(StockAdjustView): """Endpoint for counting stock (performing a stocktake).""" @@ -1371,6 +1377,7 @@ stock_api_urls = [ re_path(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'), re_path(r'^assign/', StockAssign.as_view(), name='api-stock-assign'), re_path(r'^merge/', StockMerge.as_view(), name='api-stock-merge'), + re_path(r'^change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'), # StockItemAttachment API endpoints re_path(r'^attachment/', include([ diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 77293e3542..756b510b2c 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -18,6 +18,7 @@ import common.models import company.models import InvenTree.helpers import InvenTree.serializers +import InvenTree.status_codes import part.models as part_models import stock.filters from company.serializers import SupplierPartSerializer @@ -481,6 +482,7 @@ class InstallStockItemSerializer(serializers.Serializer): note = serializers.CharField( label=_('Note'), + help_text=_('Add transaction note (optional)'), required=False, allow_blank=True, ) @@ -641,6 +643,100 @@ class ReturnStockItemSerializer(serializers.Serializer): ) +class StockChangeStatusSerializer(serializers.Serializer): + """Serializer for changing status of multiple StockItem objects""" + + class Meta: + """Metaclass options""" + fields = [ + 'items', + 'status', + 'note', + ] + + items = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=True, + required=True, + allow_null=False, + label=_('Stock Items'), + help_text=_('Select stock items to change status'), + ) + + def validate_items(self, items): + """Validate the selected stock items""" + + if len(items) == 0: + raise ValidationError(_("No stock items selected")) + + return items + + status = serializers.ChoiceField( + choices=InvenTree.status_codes.StockStatus.items(), + default=InvenTree.status_codes.StockStatus.OK.value, + label=_('Status'), + ) + + note = serializers.CharField( + label=_('Notes'), + help_text=_('Add transaction note (optional)'), + required=False, allow_blank=True, + ) + + @transaction.atomic + def save(self): + """Save the serializer to change the status of the selected stock items""" + + data = self.validated_data + + items = data['items'] + status = data['status'] + + request = self.context['request'] + user = getattr(request, 'user', None) + + note = data.get('note', '') + + items_to_update = [] + transaction_notes = [] + + deltas = { + 'status': status, + } + + now = datetime.now() + + # Instead of performing database updates for each item, + # perform bulk database updates (much more efficient) + + for item in items: + # Ignore items which are already in the desired status + if item.status == status: + continue + + item.updated = now + item.status = status + items_to_update.append(item) + + # Create a new transaction note for each item + transaction_notes.append( + StockItemTracking( + item=item, + tracking_type=InvenTree.status_codes.StockHistoryCode.EDITED.value, + date=now, + deltas=deltas, + user=user, + notes=note, + ) + ) + + # Update status + StockItem.objects.bulk_update(items_to_update, ['status', 'updated']) + + # Create entries + StockItemTracking.objects.bulk_create(transaction_notes) + + class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for a simple tree view.""" diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index b96d5c660d..e520f02bb0 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -16,7 +16,7 @@ from rest_framework import status import company.models import part.models from common.models import InvenTreeSetting -from InvenTree.status_codes import StockStatus +from InvenTree.status_codes import StockHistoryCode, StockStatus from InvenTree.unit_test import InvenTreeAPITestCase from part.models import Part from stock.models import StockItem, StockItemTestResult, StockLocation @@ -1153,6 +1153,51 @@ class StockItemTest(StockAPITestCase): stock_item.refresh_from_db() self.assertEqual(stock_item.part, variant) + def test_set_status(self): + """Test API endpoint for setting StockItem status""" + + url = reverse('api-stock-change-status') + + prt = Part.objects.first() + + # Create a bunch of items + items = [ + StockItem.objects.create(part=prt, quantity=10) for _ in range(10) + ] + + for item in items: + item.refresh_from_db() + self.assertEqual(item.status, StockStatus.OK.value) + + data = { + 'items': [item.pk for item in items], + 'status': StockStatus.DAMAGED.value, + } + + self.post(url, data, expected_code=201) + + # Check that the item has been updated correctly + for item in items: + item.refresh_from_db() + self.assertEqual(item.status, StockStatus.DAMAGED.value) + self.assertEqual(item.tracking_info.count(), 1) + + # Same test, but with one item unchanged + items[0].status = StockStatus.ATTENTION.value + items[0].save() + + data['status'] = StockStatus.ATTENTION.value + + self.post(url, data, expected_code=201) + + for item in items: + item.refresh_from_db() + self.assertEqual(item.status, StockStatus.ATTENTION.value) + self.assertEqual(item.tracking_info.count(), 2) + + tracking = item.tracking_info.last() + self.assertEqual(tracking.tracking_type, StockHistoryCode.EDITED.value) + class StocktakeTest(StockAPITestCase): """Series of tests for the Stocktake API.""" diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 77080a1205..d7df093097 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1138,7 +1138,7 @@ function adjustStock(action, items, options={}) { if (itemCount == 0) { showAlertDialog( '{% trans "Select Stock Items" %}', - '{% trans "You must select at least one available stock item" %}', + '{% trans "Select at least one available stock item" %}', ); return; @@ -2297,22 +2297,27 @@ function loadStockTable(table, options) { }); } + // Callback for 'stocktake' button $('#multi-item-stocktake').click(function() { stockAdjustment('count'); }); + // Callback for 'remove stock' button $('#multi-item-remove').click(function() { stockAdjustment('take'); }); + // Callback for 'add stock' button $('#multi-item-add').click(function() { stockAdjustment('add'); }); + // Callback for 'move stock' button $('#multi-item-move').click(function() { stockAdjustment('move'); }); + // Callback for 'merge stock' button $('#multi-item-merge').click(function() { var items = getTableData(table); @@ -2327,6 +2332,7 @@ function loadStockTable(table, options) { }); }); + // Callback for 'assign stock' button $('#multi-item-assign').click(function() { var items = getTableData(table); @@ -2338,6 +2344,7 @@ function loadStockTable(table, options) { }); }); + // Callback for 'un-assign stock' button $('#multi-item-order').click(function() { var selections = getTableData(table); @@ -2355,6 +2362,7 @@ function loadStockTable(table, options) { orderParts(parts, {}); }); + // Callback for 'delete stock' button $('#multi-item-delete').click(function() { var selections = getTableData(table); @@ -2366,6 +2374,46 @@ function loadStockTable(table, options) { stockAdjustment('delete'); }); + + // Callback for 'change status' button + $('#multi-item-status').click(function() { + let selections = getTableData(table); + let items = []; + + selections.forEach(function(item) { + items.push(item.pk); + }); + + if (items.length == 0) { + showAlertDialog( + '{% trans "Select Stock Items" %}', + '{% trans "Select one or more stock items" %}' + ); + return; + } + + let html = ` +