diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 1f8e372d39..19235f0e0a 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,14 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 23 +INVENTREE_API_VERSION = 24 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v24 -> 2022-02-10 + - Adds API endpoint for deleting (cancelling) build order outputs + v23 -> 2022-02-02 - Adds API endpoints for managing plugin classes - Adds API endpoints for managing plugin settings diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 733799f890..54204de845 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -245,6 +245,7 @@ class BuildOutputComplete(generics.CreateAPIView): ctx = super().get_serializer_context() ctx['request'] = self.request + ctx['to_complete'] = True try: ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) @@ -254,6 +255,29 @@ class BuildOutputComplete(generics.CreateAPIView): return ctx +class BuildOutputDelete(generics.CreateAPIView): + """ + API endpoint for deleting multiple build outputs + """ + + queryset = Build.objects.none() + + serializer_class = build.serializers.BuildOutputDeleteSerializer + + def get_serializer_context(self): + ctx = super().get_serializer_context() + + ctx['request'] = self.request + + try: + ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return ctx + + + class BuildFinish(generics.CreateAPIView): """ API endpoint for marking a build as finished (completed) @@ -432,6 +456,7 @@ build_api_urls = [ url(r'^(?P\d+)/', include([ url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'), + url(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'), url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'), url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 0eeffd107d..e5bb812083 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -708,7 +708,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): self.save() @transaction.atomic - def deleteBuildOutput(self, output): + def delete_output(self, output): """ Remove a build output from the database: diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 2508b02927..fe01844520 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -141,6 +141,9 @@ class BuildOutputSerializer(serializers.Serializer): build = self.context['build'] + # As this serializer can be used in multiple contexts, we need to work out why we are here + to_complete = self.context.get('to_complete', False) + # The stock item must point to the build if output.build != build: raise ValidationError(_("Build output does not match the parent build")) @@ -153,9 +156,11 @@ class BuildOutputSerializer(serializers.Serializer): if not output.is_building: raise ValidationError(_("This build output has already been completed")) - # The build output must have all tracked parts allocated - if not build.isFullyAllocated(output): - raise ValidationError(_("This build output is not fully allocated")) + if to_complete: + + # The build output must have all tracked parts allocated + if not build.isFullyAllocated(output): + raise ValidationError(_("This build output is not fully allocated")) return output @@ -165,6 +170,50 @@ class BuildOutputSerializer(serializers.Serializer): ] +class BuildOutputDeleteSerializer(serializers.Serializer): + """ + DRF serializer for deleting (cancelling) one or more build outputs + """ + + class Meta: + fields = [ + 'outputs', + ] + + outputs = BuildOutputSerializer( + many=True, + required=True, + ) + + def validate(self, data): + + data = super().validate(data) + + outputs = data.get('outputs', []) + + if len(outputs) == 0: + raise ValidationError(_("A list of build outputs must be provided")) + + return data + + def save(self): + """ + 'save' the serializer to delete the build outputs + """ + + data = self.validated_data + outputs = data.get('outputs', []) + + build = self.context['build'] + + with transaction.atomic(): + for item in outputs: + + output = item['output'] + + build.delete_output(output) + + class BuildOutputCompleteSerializer(serializers.Serializer): """ DRF serializer for completing one or more build outputs diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index b548632b56..ff335d139c 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -243,13 +243,16 @@
-
{% include "filter_list.html" with id='incompletebuilditems' %} @@ -372,6 +375,7 @@ inventreeGet( [ '#output-options', '#multi-output-complete', + '#multi-output-delete', ] ); @@ -393,6 +397,24 @@ inventreeGet( ); }); + $('#multi-output-delete').click(function() { + var outputs = $('#build-output-table').bootstrapTable('getSelections'); + + deleteBuildOutputs( + build_info.pk, + outputs, + { + success: function() { + // Reload the "in progress" table + $('#build-output-table').bootstrapTable('refresh'); + + // Reload the "completed" table + $('#build-stock-table').bootstrapTable('refresh'); + } + } + ) + }); + {% endif %} {% if build.active and build.has_untracked_bom_items %} diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 3868ac1b09..7a5860d285 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -417,6 +417,145 @@ function completeBuildOutputs(build_id, outputs, options={}) { } + +/** + * Launch a modal form to delete selected build outputs + */ +function deleteBuildOutputs(build_id, outputs, options={}) { + + if (outputs.length == 0) { + showAlertDialog( + '{% trans "Select Build Outputs" %}', + '{% trans "At least one build output must be selected" %}', + ); + return; + } + + // Render a single build output (StockItem) + function renderBuildOutput(output, opts={}) { + var pk = output.pk; + + var output_html = imageHoverIcon(output.part_detail.thumbnail); + + if (output.quantity == 1 && output.serial) { + output_html += `{% trans "Serial Number" %}: ${output.serial}`; + } else { + output_html += `{% trans "Quantity" %}: ${output.quantity}`; + } + + var buttons = `
`; + + buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}'); + + buttons += '
'; + + var field = constructField( + `outputs_output_${pk}`, + { + type: 'raw', + html: output_html, + }, + { + hideLabels: true, + } + ); + + var html = ` + + ${field} + ${output.part_detail.full_name} + ${buttons} + `; + + return html; + } + + // Construct table entries + var table_entries = ''; + + outputs.forEach(function(output) { + table_entries += renderBuildOutput(output); + }); + + var html = ` + + + + + + + ${table_entries} + +
{% trans "Output" %}
`; + + constructForm(`/api/build/${build_id}/delete-outputs/`, { + method: 'POST', + preFormContent: html, + fields: {}, + confirm: true, + title: '{% trans "Delete Build Outputs" %}', + afterRender: function(fields, opts) { + // Setup callbacks to remove outputs + $(opts.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(opts.modal).find(`#output_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + var data = { + outputs: [], + }; + + var output_pk_values = []; + + outputs.forEach(function(output) { + var pk = output.pk; + + var row = $(opts.modal).find(`#output_row_${pk}`); + + if (row.exists()) { + data.outputs.push({ + output: pk + }); + output_pk_values.push(pk); + } + }); + + opts.nested = { + 'outputs': output_pk_values, + }; + + inventreePut( + opts.url, + data, + { + method: 'POST', + success: function(response) { + $(opts.modal).modal('hide'); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr, opts.url); + break; + } + } + } + ) + } + }); +} + + /** * Load a table showing all the BuildOrder allocations for a given part */ @@ -604,15 +743,17 @@ function loadBuildOutputTable(build_info, options={}) { $(table).find('.button-output-delete').click(function() { var pk = $(this).attr('pk'); - // TODO: Move this to the API - launchModalForm( - `/build/${build_info.pk}/delete-output/`, + var output = $(table).bootstrapTable('getRowByUniqueId', pk); + + deleteBuildOutputs( + build_info.pk, + [ + output, + ], { - data: { - output: pk - }, success: function() { $(table).bootstrapTable('refresh'); + $('#build-stock-table').bootstrapTable('refresh'); } } );