diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 921a45c112..37a8bf82e1 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -337,7 +337,7 @@ def DownloadFile(data, filename, content_type='application/text'): return response -def ExtractSerialNumbers(serials, expected_quantity): +def extract_serial_numbers(serials, expected_quantity): """ Attempt to extract serial numbers from an input string. - Serial numbers must be integer values - Serial numbers must be positive diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index c46a059c8d..6630c0b0af 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -210,7 +210,7 @@ class TestSerialNumberExtraction(TestCase): def test_simple(self): - e = helpers.ExtractSerialNumbers + e = helpers.extract_serial_numbers sn = e("1-5", 5) self.assertEqual(len(sn), 5) @@ -226,7 +226,7 @@ class TestSerialNumberExtraction(TestCase): def test_failures(self): - e = helpers.ExtractSerialNumbers + e = helpers.extract_serial_numbers # Test duplicates with self.assertRaises(ValidationError): diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 57f80f1be7..e46eb80bf6 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -213,26 +213,6 @@ class AjaxMixin(InvenTreeRoleMixin): """ return {} - def pre_save(self, obj, form, **kwargs): - """ - Hook for doing something *before* an object is saved. - - obj: The object to be saved - form: The cleaned form - """ - - # Do nothing by default - pass - - def post_save(self, obj, form, **kwargs): - """ - Hook for doing something *after* an object is saved. - - """ - - # Do nothing by default - pass - def validate(self, obj, form, **kwargs): """ Hook for performing custom form validation steps. @@ -362,7 +342,7 @@ class AjaxCreateView(AjaxMixin, CreateView): form = self.get_form() return self.renderJsonResponse(request, form) - def do_save(self, form): + def save(self, form): """ Method for actually saving the form to the database. Default implementation is very simple, @@ -402,14 +382,10 @@ class AjaxCreateView(AjaxMixin, CreateView): if valid: - # Perform (optional) pre-save step - self.pre_save(None, self.form) - # Save the object to the database - self.do_save(self.form) + self.save(self.form) - # Perform (optional) post-save step - self.post_save(self.object, self.form) + self.object = self.get_object() # Return the PK of the newly-created object data['pk'] = self.object.pk @@ -440,11 +416,14 @@ class AjaxUpdateView(AjaxMixin, UpdateView): return self.renderJsonResponse(request, self.get_form(), context=self.get_context_data()) - def do_save(self, form): + def save(self, object, form, **kwargs): """ Method for updating the object in the database. - Default implementation is very simple, - but can be overridden if required. + Default implementation is very simple, but can be overridden if required. + + Args: + object - The current object, to be updated + form - The validated form """ self.object = form.save() @@ -485,22 +464,16 @@ class AjaxUpdateView(AjaxMixin, UpdateView): if valid: - # Perform (optional) pre-save step - self.pre_save(self.object, form) - # Save the updated objec to the database - obj = self.do_save(form) + self.save(self.object, form) - # Perform (optional) post-save step - self.post_save(obj, form) + self.object = self.get_object() # Include context data about the updated object - data['pk'] = obj.pk - - self.post_save(obj, form) + data['pk'] = self.object.pk try: - data['url'] = obj.get_absolute_url() + data['url'] = self.object.get_absolute_url() except AttributeError: pass diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index 0d90797467..508dd1a45a 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -32,12 +32,6 @@ class EditBuildForm(HelperForm): 'reference': _('Build Order reference') } - serial_numbers = forms.CharField( - label=_('Serial Numbers'), - help_text=_('Serial numbers for build outputs'), - required=False, - ) - class Meta: model = Build fields = [ @@ -46,7 +40,6 @@ class EditBuildForm(HelperForm): 'part', 'quantity', 'batch', - 'serial_numbers', 'take_from', 'destination', 'parent', @@ -55,6 +48,42 @@ class EditBuildForm(HelperForm): ] +class BuildOutputCreateForm(HelperForm): + """ + Form for creating a new build output. + """ + + field_prefix = { + 'serial_numbers': 'fa-hashtag', + } + + quantity = forms.IntegerField( + label=_('Quantity'), + help_text=_('Enter quantity for build output'), + ) + + serial_numbers = forms.CharField( + label=_('Serial numbers'), + required=False, + help_text=_('Enter serial numbers for build outputs'), + ) + + confirm = forms.BooleanField( + required=True, + label=_('Confirm'), + help_text=_('Confirm creation of build outut'), + ) + + class Meta: + model = Build + fields = [ + 'quantity', + 'batch', + 'serial_numbers', + 'confirm', + ] + + class BuildOutputDeleteForm(HelperForm): """ Form for deleting a build output. @@ -123,7 +152,27 @@ class AutoAllocateForm(HelperForm): class CompleteBuildForm(HelperForm): - """ Form for marking a Build as complete """ + """ + Form for marking a build as complete + """ + + confirm = forms.BooleanField( + required=True, + label=_('Confirm'), + help_text=_('Mark build as complete'), + ) + + class Meta: + model = Build + fields = [ + 'confirm', + ] + + +class CompleteBuildOutputForm(HelperForm): + """ + Form for completing a single build output + """ field_prefix = { 'serial_numbers': 'fa-hashtag', diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 03997e2353..1cb79b57a1 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -250,7 +250,7 @@ class Build(MPTTModel): @property def incomplete_outputs(self): """ - Return all the "incomplete" build outputs" + Return all the "incomplete" build outputs """ outputs = self.get_build_outputs(complete=False) @@ -259,6 +259,19 @@ class Build(MPTTModel): return outputs + @property + def incomplete_count(self): + """ + Return the total number of "incomplete" outputs + """ + + quantity = 0 + + for output in self.incomplete_outputs: + quantity += output.quantity + + return quantity + @classmethod def getNextBuildNumber(cls): """ @@ -291,6 +304,37 @@ class Build(MPTTModel): return new_ref + @property + def can_complete(self): + """ + Returns True if this build can be "completed" + + - Must not have any outstanding build outputs + - 'completed' value must meet (or exceed) the 'quantity' value + """ + + if self.incomplete_count > 0: + return False + + if self.completed < self.quantity: + return False + + # No issues! + return True + + def completeBuild(self, user): + """ + Mark this build as complete + """ + + if not self.can_complete: + return + + self.completion_date = datetime.now().date() + self.completed_by = user + self.status = BuildStatus.COMPLETE + self.save() + @transaction.atomic def cancelBuild(self, user): """ Mark the Build as CANCELLED @@ -408,6 +452,77 @@ class Build(MPTTModel): # Remove all the allocations allocations.delete() + @transaction.atomic + def create_build_output(self, quantity, **kwargs): + """ + Create a new build output against this BuildOrder. + + args: + quantity: The quantity of the item to produce + + kwargs: + batch: Override batch code + serials: Serial numbers + location: Override location + """ + + batch = kwargs.get('batch', self.batch) + location = kwargs.get('location', self.destination) + serials = kwargs.get('serials', None) + + """ + Determine if we can create a single output (with quantity > 0), + or multiple outputs (with quantity = 1) + """ + + multiple = False + + # Serial numbers are provided? We need to split! + if serials: + multiple = True + + # BOM has trackable parts, so we must split! + if self.part.has_trackable_parts: + multiple = True + + if multiple: + """ + Create multiple build outputs with a single quantity of 1 + """ + + for ii in range(quantity): + + if serials: + serial = serials[ii] + else: + serial = None + + output = StockModels.StockItem.objects.create( + quantity=1, + location=location, + part=self.part, + build=self, + batch=batch, + serial=serial, + is_building=True, + ) + + else: + """ + Create a single build output of the given quantity + """ + + output = StockModels.StockItem.objects.create( + quantity=quantity, + location=location, + part=self.part, + build=self, + batch=batch, + is_building=True + ) + + + @transaction.atomic def deleteBuildOutput(self, output): """ Remove a build output from the database: diff --git a/InvenTree/build/templates/build/allocate.html b/InvenTree/build/templates/build/allocate.html index bae06c2c16..ee5fb10d89 100644 --- a/InvenTree/build/templates/build/allocate.html +++ b/InvenTree/build/templates/build/allocate.html @@ -11,11 +11,19 @@ InvenTree | Allocate Parts {% include "build/tabs.html" with tab='allocate' %} +

{% trans "Incomplete Build Ouputs" %}

+ +
+
- + +
-

{% trans "Incomplete Build Ouputs" %}

{% for item in build.incomplete_outputs %} {% include "build/allocation_card.html" with item=item %} @@ -57,388 +65,6 @@ InvenTree | Allocate Parts ); {% endfor %} - var buildTable = $("#build-item-list"); - - // Calculate sum of allocations for a particular table row - function sumAllocations(row) { - if (row.allocations == null) { - return 0; - } - - var quantity = 0; - - row.allocations.forEach(function(item) { - quantity += item.quantity; - }); - - return quantity; - } - - function getUnallocated(row) { - // Return the number of items remaining to be allocated for a given row - return {{ build.quantity }} * row.quantity - sumAllocations(row); - } - - function setExpandedAllocatedLocation(row) { - // Handle case when stock item does not have a location set - if (row.location_detail == null) { - return 'No stock location set'; - } else { - return row.location_detail.pathstring; - } - } - - function reloadTable() { - // Reload the build allocation table - buildTable.bootstrapTable('refresh'); - } - - function setupCallbacks() { - // Register button callbacks once the table data are loaded - - buildTable.find(".button-add").click(function() { - var pk = $(this).attr('pk'); - - // Extract row data from the table - var idx = $(this).closest('tr').attr('data-index'); - var row = buildTable.bootstrapTable('getData')[idx]; - - launchModalForm('/build/item/new/', { - success: reloadTable, - data: { - part: row.sub_part, - build: {{ build.id }}, - quantity: getUnallocated(row), - }, - secondary: [ - { - field: 'stock_item', - label: '{% trans "New Stock Item" %}', - title: '{% trans "Create new Stock Item" %}', - url: '{% url "stock-item-create" %}', - data: { - part: row.sub_part, - }, - }, - ] - }); - }); - - - buildTable.find(".button-build").click(function() { - // Start a new build for the sub_part - - var pk = $(this).attr('pk'); - - // Extract row data from the table - var idx = $(this).closest('tr').attr('data-index'); - var row = buildTable.bootstrapTable('getData')[idx]; - - launchModalForm('/build/new/', { - follow: true, - data: { - part: row.sub_part, - parent: {{ build.id }}, - quantity: getUnallocated(row), - }, - }); - - }); - - buildTable.find(".button-buy").click(function() { - var pk = $(this).attr('pk'); - - // Extract row data from the table - var idx = $(this).closest('tr').attr('data-index'); - var row = buildTable.bootstrapTable('getData')[idx]; - - launchModalForm("{% url 'order-parts' %}", { - data: { - parts: [row.sub_part], - }, - }); - }); - } - - buildTable.inventreeTable({ - uniqueId: 'sub_part', - url: "{% url 'api-bom-list' %}", - onPostBody: setupCallbacks, - detailViewByClick: true, - detailView: true, - detailFilter: function(index, row) { - return row.allocations != null; - }, - detailFormatter: function(index, row, element) { - // Construct an 'inner table' which shows the stock allocations - - var subTableId = `allocation-table-${row.pk}`; - - var html = `
`; - - element.html(html); - - var lineItem = row; - - var subTable = $(`#${subTableId}`); - - subTable.bootstrapTable({ - data: row.allocations, - showHeader: true, - columns: [ - { - width: '50%', - field: 'quantity', - title: 'Quantity', - formatter: function(value, row, index, field) { - var text = ''; - - var url = ''; - - if (row.serial && row.quantity == 1) { - text = `{% trans "Serial Number" %}: ${row.serial}`; - } else { - text = `{% trans "Quantity" %}: ${row.quantity}`; - } - - {% if build.status == BuildStatus.COMPLETE %} - url = `/stock/item/${row.pk}/`; - {% else %} - url = `/stock/item/${row.stock_item}/`; - {% endif %} - - return renderLink(text, url); - }, - }, - { - field: 'location', - title: '{% trans "Location" %}', - formatter: function(value, row, index, field) { - {% if build.status == BuildStatus.COMPLETE %} - var text = setExpandedAllocatedLocation(row); - var url = `/stock/location/${row.location}/`; - {% else %} - var text = row.stock_item_detail.location_name; - var url = `/stock/location/${row.stock_item_detail.location}/`; - {% endif %} - - return renderLink(text, url); - } - }, - {% if build.status == BuildStatus.PENDING %} - { - field: 'buttons', - title: 'Actions', - formatter: function(value, row) { - - var pk = row.pk; - - var html = `
`; - - {% if build.status == BuildStatus.PENDING %} - html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); - {% endif %} - - html += `
`; - - return html; - }, - }, - {% endif %} - ] - }); - - // Assign button callbacks to the newly created allocation buttons - subTable.find(".button-allocation-edit").click(function() { - var pk = $(this).attr('pk'); - launchModalForm(`/build/item/${pk}/edit/`, { - success: reloadTable, - }); - }); - - subTable.find('.button-allocation-delete').click(function() { - var pk = $(this).attr('pk'); - launchModalForm(`/build/item/${pk}/delete/`, { - success: reloadTable, - }); - }); - }, - formatNoMatches: function() { return "{% trans 'No BOM items found' %}"; }, - onLoadSuccess: function(tableData) { - // Once the BOM data are loaded, request allocation data for the build - {% if build.status == BuildStatus.COMPLETE %} - // Request StockItem which have been assigned to this build - inventreeGet('/api/stock/', - { - build_order: {{ build.id }}, - location_detail: true, - }, - { - success: function(data) { - // Iterate through the returned data, group by "part", - var allocations = {}; - - data.forEach(function(item) { - // Group allocations by referenced 'part' - var key = parseInt(item.part); - - if (!(key in allocations)) { - allocations[key] = new Array(); - } - - allocations[key].push(item); - }); - - for (var key in allocations) { - - var tableRow = buildTable.bootstrapTable('getRowByUniqueId', key); - - tableRow.allocations = allocations[key]; - - buildTable.bootstrapTable('updateByUniqueId', key, tableRow, true); - } - }, - }, - ); - - {% else %} - inventreeGet('/api/build/item/', - { - build: {{ build.id }}, - }, - { - success: function(data) { - - // Iterate through the returned data, and group by "part" - var allocations = {}; - - data.forEach(function(item) { - - // Group allocations by referenced 'part' - var part = item.part; - var key = parseInt(part); - - if (!(key in allocations)) { - allocations[key] = new Array(); - } - - // Add the allocation to the list - allocations[key].push(item); - }); - - for (var key in allocations) { - - // Select the associated row in the table - var tableRow = buildTable.bootstrapTable('getRowByUniqueId', key); - - // Set the allocations for the row - tableRow.allocations = allocations[key]; - - // And push the updated row back into the main table - buildTable.bootstrapTable('updateByUniqueId', key, tableRow, true); - } - } - }, - ); - {% endif %} - }, - queryParams: { - part: {{ build.part.id }}, - sub_part_detail: 1, - }, - columns: [ - { - field: 'id', - visible: false, - }, - { - sortable: true, - field: 'sub_part', - title: '{% trans "Part" %}', - formatter: function(value, row, index, field) { - return imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, `/part/${row.sub_part}/`); - }, - }, - { - sortable: true, - field: 'sub_part_detail.description', - title: '{% trans "Description" %}', - }, - { - sortable: true, - field: 'reference', - title: '{% trans "Reference" %}', - }, - { - sortable: true, - field: 'quantity', - title: '{% trans "Required" %}', - formatter: function(value, row) { - return value * {{ build.quantity }}; - }, - }, - { - sortable: true, - field: 'allocated', - {% if build.status == BuildStatus.COMPLETE %} - title: '{% trans "Assigned" %}', - {% else %} - title: '{% trans "Allocated" %}', - {% endif %} - formatter: function(value, row) { - - var allocated = sumAllocations(row); - - return makeProgressBar(allocated, row.quantity * {{ build.quantity }}); - }, - sorter: function(valA, valB, rowA, rowB) { - - var aA = sumAllocations(rowA); - var aB = sumAllocations(rowB); - - var qA = rowA.quantity * {{ build.quantity }}; - var qB = rowB.quantity * {{ build.quantity }}; - - if (aA == 0 && aB == 0) { - return (qA > qB) ? 1 : -1; - } - - var progressA = parseFloat(aA) / qA; - var progressB = parseFloat(aB) / qB; - - return (progressA < progressB) ? 1 : -1; - } - }, - {% if build.status == BuildStatus.PENDING %} - { - field: 'buttons', - formatter: function(value, row, index, field) { - - var html = `
`; - var pk = row.sub_part; - - {% if build.status == BuildStatus.PENDING %} - if (row.sub_part_detail.purchaseable) { - html += makeIconButton('fa-shopping-cart', 'button-buy', pk, '{% trans "Buy parts" %}'); - } - - if (row.sub_part_detail.assembly) { - html += makeIconButton('fa-tools', 'button-build', pk, '{% trans "Build parts" %}'); - } - - html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate stock" %}'); - {% endif %} - - html += '
'; - - return html; - }, - } - {% endif %} - ], - }); - {% if build.status == BuildStatus.PENDING %} $("#btn-allocate").on('click', function() { launchModalForm( @@ -457,6 +83,14 @@ InvenTree | Allocate Parts } ); }); + + $('#btn-create-output').click(function() { + launchModalForm('{% url "build-output-create" build.id %}', + { + reload: true, + } + ); + }); $("#btn-order-parts").click(function() { launchModalForm("/order/purchase-order/order-parts/", { diff --git a/InvenTree/build/templates/build/build_output_create.html b/InvenTree/build/templates/build/build_output_create.html new file mode 100644 index 0000000000..5de41695a6 --- /dev/null +++ b/InvenTree/build/templates/build/build_output_create.html @@ -0,0 +1,20 @@ +{% extends "modal_form.html" %} +{% load i18n %} +{% block pre_form_content %} + +{% if build.part.has_trackable_parts %} +
+ {% trans "The Bill of Materials contains trackable parts" %}
+ {% trans "Build outputs must be generated individually." %}
+ {% trans "Multiple build outputs will be created based on the quantity specified." %} +
+{% endif %} + +{% if build.part.trackable %} +
+ {% trans "Trackable parts can have serial numbers specified" %}
+ {% trans "Enter serial numbers to generate multiple single build outputs" %} +
+{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/cancel.html b/InvenTree/build/templates/build/cancel.html index d7e4d51b10..48d8ca09bd 100644 --- a/InvenTree/build/templates/build/cancel.html +++ b/InvenTree/build/templates/build/cancel.html @@ -1,7 +1,7 @@ {% extends "modal_form.html" %} - +{% load i18n %} {% block pre_form_content %} -Are you sure you wish to cancel this build? +{% trans "Are you sure you wish to cancel this build?" %} {% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/complete.html b/InvenTree/build/templates/build/complete.html index 54c3fc6763..0db6a336a5 100644 --- a/InvenTree/build/templates/build/complete.html +++ b/InvenTree/build/templates/build/complete.html @@ -1,48 +1,23 @@ {% extends "modal_form.html" %} -{% load inventree_extras %} {% load i18n %} {% block pre_form_content %} -{% if fully_allocated %} -
-

{% trans "Stock allocation is complete" %}

+{% if build.can_complete %} +
+ {% trans "Build can be completed" %}
{% else %}
-

{% trans "Stock allocation is incomplete" %}

- -
-
- -
-
-
    - {% for part in unallocated_parts %} -
  • - {% include "hover_image.html" with image=part.image %} {{ part }} -
  • - {% endfor %} -
-
-
-
-
+ {% trans "Build cannot be completed" %}
+
    + {% if build.incomplete_count > 0 %} +
  • {% trans "Incompleted build outputs remain" %}
  • + {% endif %} + {% if build.completed < build.quantity %} +
  • {% trans "Required build quantity has not been completed" %}
  • + {% endif %} +
{% endif %} - -
-
- {% trans "The following items will be created" %} -
-
- {% include "hover_image.html" with image=build.part.image hover=True %} - {% decimal output.quantity %} x {{ output.part.full_name }} -
-
- {% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/complete_output.html b/InvenTree/build/templates/build/complete_output.html new file mode 100644 index 0000000000..54c3fc6763 --- /dev/null +++ b/InvenTree/build/templates/build/complete_output.html @@ -0,0 +1,48 @@ +{% extends "modal_form.html" %} +{% load inventree_extras %} +{% load i18n %} + +{% block pre_form_content %} + +{% if fully_allocated %} +
+

{% trans "Stock allocation is complete" %}

+
+{% else %} +
+

{% trans "Stock allocation is incomplete" %}

+ +
+
+ +
+
+
    + {% for part in unallocated_parts %} +
  • + {% include "hover_image.html" with image=part.image %} {{ part }} +
  • + {% endfor %} +
+
+
+
+
+
+{% endif %} + +
+
+ {% trans "The following items will be created" %} +
+
+ {% include "hover_image.html" with image=build.part.image hover=True %} + {% decimal output.quantity %} x {{ output.part.full_name }} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 37d877a905..4e65a3a416 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -10,7 +10,7 @@ from stock.models import StockItem from part.models import Part, BomItem from InvenTree import status_codes as status -from InvenTree.helpers import ExtractSerialNumbers +from InvenTree.helpers import extract_serial_numbers class BuildTest(TestCase): @@ -188,7 +188,7 @@ class BuildTest(TestCase): self.assertTrue(self.build.isFullyAllocated()) # Generate some serial numbers! - serials = ExtractSerialNumbers("1-10", 10) + serials = extract_serial_numbers("1-10", 10) self.build.completeBuild(None, serials, None) diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index ff49fdd486..08142e6939 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -11,10 +11,12 @@ build_detail_urls = [ url(r'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'), url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'), url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'), + url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'), url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'), - url(r'^complete/?', views.BuildComplete.as_view(), name='build-complete'), + url(r'^complete-output/?', views.BuildOutputComplete.as_view(), name='build-output-complete'), url(r'^auto-allocate/?', views.BuildAutoAllocate.as_view(), name='build-auto-allocate'), url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'), + url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'), url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'), diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 4716fd7583..04c013d381 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -18,7 +18,7 @@ from stock.models import StockLocation, StockItem from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView from InvenTree.views import InvenTreeRoleMixin -from InvenTree.helpers import str2bool, ExtractSerialNumbers, normalize +from InvenTree.helpers import str2bool, extract_serial_numbers, normalize from InvenTree.status_codes import BuildStatus @@ -67,11 +67,13 @@ class BuildCancel(AjaxUpdateView): if not confirm: form.add_error('confirm_cancel', _('Confirm build cancellation')) - def post_save(self, build, form, **kwargs): + def save(self, form, **kwargs): """ Cancel the build. """ + build = self.get_object() + build.cancelBuild(self.request.user) def get_data(self): @@ -156,13 +158,12 @@ class BuildAutoAllocate(AjaxUpdateView): if not output: form.add_error(None, _('Build output must be specified')) - def post_save(self, build, form, **kwargs): + def save(self, build, form, **kwargs): """ Once the form has been validated, perform auto-allocations """ - build = self.get_object() output = form.cleaned_data.get('output', None) build.autoAllocate(output) @@ -173,6 +174,99 @@ class BuildAutoAllocate(AjaxUpdateView): } +class BuildOutputCreate(AjaxUpdateView): + """ + Create a new build output (StockItem) for a given build. + """ + + model = Build + form_class = forms.BuildOutputCreateForm + ajax_template_name = 'build/build_output_create.html' + ajax_form_title = _('Create Build Output') + role_required = 'build.change' + + def validate(self, build, form, **kwargs): + """ + Validation for the form: + """ + + quantity = form.cleaned_data.get('quantity', None) + serials = form.cleaned_data.get('serial_numbers', None) + + # Check that the serial numbers are valid + if serials: + try: + extracted = extract_serial_numbers(serials, quantity) + + if extracted: + # Check for conflicting serial numbers + conflicts = build.part.find_conflicting_serial_numbers(extracted) + + if len(conflicts) > 0: + msg = ",".join([str(c) for c in conflicts]) + form.add_error( + 'serial_numbers', + _('Serial numbers already exist') + ': ' + msg, + ) + + except ValidationError as e: + form.add_error('serial_numbers', e.messages) + + else: + # If no serial numbers are provided, should they be? + if build.part.trackable: + form.add_error('serial_numbers', _('Serial numbers required for trackable build output')) + + def save(self, build, form, **kwargs): + """ + Create a new build output + """ + + data = form.cleaned_data + + quantity = data.get('quantity', None) + batch = data.get('batch', None) + + serials = data.get('serial_numbers', None) + + if serials: + serial_numbers = extract_serial_numbers(serials, quantity) + else: + serial_numbers = None + + build.create_build_output( + quantity, + serials=serial_numbers, + batch=batch, + ) + + + def get_initial(self): + + initials = super().get_initial() + + build = self.get_object() + + # Calculate the required quantity + quantity = max(0, build.remaining - build.incomplete_count) + initials['quantity'] = quantity + + return initials + + def get_form(self): + + form = super().get_form() + + build = self.get_object() + part = build.part + + # If the part is not trackable, hide the serial number input + if not part.trackable: + form.fields['serial_numbers'] = HiddenInput() + + return form + + class BuildOutputDelete(AjaxUpdateView): """ Delete a build output (StockItem) for a given build. @@ -182,7 +276,7 @@ class BuildOutputDelete(AjaxUpdateView): model = Build form_class = forms.BuildOutputDeleteForm - ajax_form_title = _('Delete build output') + ajax_form_title = _('Delete Build Output') role_required = 'build.delete' def get_initial(self): @@ -296,7 +390,24 @@ class BuildUnallocate(AjaxUpdateView): class BuildComplete(AjaxUpdateView): - """ View to mark a build as Complete. + """ + View to mark the build as complete. + + Requirements: + - There can be no outstanding build outputs + - The "completed" value must meet or exceed the "quantity" value + """ + + model = Build + form_class = forms.CompleteBuildForm + role_required = 'build.change' + ajax_form_title = _('Complete Build Order') + ajax_template_name = 'build/complete.html' + + +class BuildOutputComplete(AjaxUpdateView): + """ + View to mark a particular build output as Complete. - Notifies the user of which parts will be removed from stock. - Removes allocated items from stock @@ -304,10 +415,10 @@ class BuildComplete(AjaxUpdateView): """ model = Build - form_class = forms.CompleteBuildForm + form_class = forms.CompleteBuildOutputForm context_object_name = "build" ajax_form_title = _("Complete Build Output") - ajax_template_name = "build/complete.html" + ajax_template_name = "build/complete_output.html" role_required = 'build.change' def get_form(self): @@ -422,7 +533,7 @@ class BuildComplete(AjaxUpdateView): return context - def post_save(self, build, form, **kwargs): + def save(self, build, form, **kwargs): data = form.cleaned_data @@ -593,7 +704,7 @@ class BuildCreate(AjaxCreateView): # Check that the provided serial numbers are sensible try: - extracted = ExtractSerialNumbers(serials, quantity) + extracted = extract_serial_numbers(serials, quantity) except ValidationError as e: extracted = None form.add_error('serial_numbers', e.messages) @@ -912,9 +1023,14 @@ class BuildAttachmentCreate(AjaxCreateView): ajax_form_title = _('Add Build Order Attachment') role_required = 'build.add' - def post_save(self, **kwargs): - self.object.user = self.request.user - self.object.save() + def save(self, form, **kwargs): + """ + Add information on the user that uploaded the attachment + """ + + attachment = form.save(commit=False) + attachment.user = self.request.user + attachment.save() def get_data(self): return { diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 2db5b44c4b..d0e838a08b 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -100,7 +100,9 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView): ajax_template_name = "modal_form.html" role_required = 'purchase_order.add' - def post_save(self, attachment, form, **kwargs): + def save(self, form, **kwargs): + + attachment = form.save(commit=False) attachment.user = self.request.user attachment.save() @@ -148,9 +150,14 @@ class SalesOrderAttachmentCreate(AjaxCreateView): ajax_form_title = _('Add Sales Order Attachment') role_required = 'sales_order.add' - def post_save(self, attachment, form, **kwargs): - self.object.user = self.request.user - self.object.save() + def save(self, form, **kwargs): + """ + Save the user that uploaded the attachment + """ + + attachment = form.save(commit=False) + attachment.user = self.request.user + attachment.save() def get_data(self): return { @@ -295,7 +302,9 @@ class SalesOrderNotes(InvenTreeRoleMixin, UpdateView): class PurchaseOrderCreate(AjaxCreateView): - """ View for creating a new PurchaseOrder object using a modal form """ + """ + View for creating a new PurchaseOrder object using a modal form + """ model = PurchaseOrder ajax_form_title = _("Create Purchase Order") @@ -319,9 +328,12 @@ class PurchaseOrderCreate(AjaxCreateView): return initials - def post_save(self, order, form, **kwargs): - # Record the user who created this purchase order + def save(self, form, **kwargs): + """ + Record the user who created this PurchaseOrder + """ + order = form.save(commit=False) order.created_by = self.request.user order.save() @@ -351,8 +363,12 @@ class SalesOrderCreate(AjaxCreateView): return initials - def post_save(self, order, form, **kwargs): - # Record the user who created this sales order + def save(self, form, **kwargs): + """ + Record the user who created this SalesOrder + """ + + order = form.save(commit=False) order.created_by = self.request.user order.save() @@ -414,7 +430,10 @@ class PurchaseOrderCancel(AjaxUpdateView): if not order.can_cancel(): form.add_error(None, _('Order cannot be cancelled')) - def post_save(self, order, form, **kwargs): + def save(self, order, form, **kwargs): + """ + Cancel the PurchaseOrder + """ order.cancel_order() @@ -438,7 +457,10 @@ class SalesOrderCancel(AjaxUpdateView): if not order.can_cancel(): form.add_error(None, _('Order cannot be cancelled')) - def post_save(self, order, form, **kwargs): + def save(self, order, form, **kwargs): + """ + Once the form has been validated, cancel the SalesOrder + """ order.cancel_order() @@ -459,8 +481,10 @@ class PurchaseOrderIssue(AjaxUpdateView): if not confirm: form.add_error('confirm', _('Confirm order placement')) - def post_save(self, order, form, **kwargs): - + def save(self, order, form, **kwargs): + """ + Once the form has been validated, place the order. + """ order.place_order() def get_data(self): @@ -495,7 +519,10 @@ class PurchaseOrderComplete(AjaxUpdateView): if not confirm: form.add_error('confirm', _('Confirm order completion')) - def post_save(self, order, form, **kwargs): + def save(self, order, form, **kwargs): + """ + Complete the PurchaseOrder + """ order.complete_order() diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 4544a09b66..6a64f595b5 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -905,6 +905,7 @@ class Part(MPTTModel): def has_bom(self): return self.bom_count > 0 + @property def has_trackable_parts(self): """ Return True if any parts linked in the Bill of Materials are trackable. diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 7eb7ff3c65..f88f7f415a 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -82,10 +82,14 @@ class PartAttachmentCreate(AjaxCreateView): role_required = 'part.add' - def post_save(self): - """ Record the user that uploaded the attachment """ - self.object.user = self.request.user - self.object.save() + def save(self, form, **kwargs): + """ + Record the user that uploaded this attachment + """ + + attachment = form.save(commit=False) + attachment.user = self.request.user + attachment.save() def get_data(self): return { @@ -874,7 +878,10 @@ class BomDuplicate(AjaxUpdateView): if not confirm: form.add_error('confirm', _('Confirm duplication of BOM from parent')) - def post_save(self, part, form): + def save(self, part, form): + """ + Duplicate BOM from the specified parent + """ parent = form.cleaned_data.get('parent', None) @@ -915,7 +922,10 @@ class BomValidate(AjaxUpdateView): if not confirm: form.add_error('validate', _('Confirm that the BOM is valid')) - def post_save(self, part, form, **kwargs): + def save(self, part, form, **kwargs): + """ + Mark the BOM as validated + """ part.validate_bom(self.request.user) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index b25022fbdc..2e45ceb00e 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -21,7 +21,7 @@ from InvenTree.views import InvenTreeRoleMixin from InvenTree.forms import ConfirmForm from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats -from InvenTree.helpers import ExtractSerialNumbers +from InvenTree.helpers import extract_serial_numbers from decimal import Decimal, InvalidOperation from datetime import datetime @@ -164,9 +164,10 @@ class StockItemAttachmentCreate(AjaxCreateView): ajax_template_name = "modal_form.html" role_required = 'stock.add' - def post_save(self, attachment, form, **kwargs): + def save(self, form, **kwargs): """ Record the user that uploaded the attachment """ + attachment = form.save(commit=False) attachment.user = self.request.user attachment.save() @@ -252,7 +253,7 @@ class StockItemAssignToCustomer(AjaxUpdateView): if not customer: form.add_error('customer', _('Customer must be specified')) - def post_save(self, item, form, **kwargs): + def save(self, item, form, **kwargs): """ Assign the stock item to the customer. """ @@ -286,7 +287,7 @@ class StockItemReturnToStock(AjaxUpdateView): if not location: form.add_error('location', _('Specify a valid location')) - def post_save(self, item, form, **kwargs): + def save(self, item, form, **kwargs): location = form.cleaned_data.get('location', None) @@ -431,9 +432,12 @@ class StockItemTestResultCreate(AjaxCreateView): ajax_form_title = _("Add Test Result") role_required = 'stock.add' - def post_save(self, result, form, **kwargs): - """ Record the user that uploaded the test result """ + def save(self, form, **kwargs): + """ + Record the user that uploaded the test result + """ + result = form.save(commit=False) result.user = self.request.user result.save() @@ -1405,7 +1409,7 @@ class StockItemSerialize(AjaxUpdateView): destination = None try: - numbers = ExtractSerialNumbers(serials, quantity) + numbers = extract_serial_numbers(serials, quantity) except ValidationError as e: form.add_error('serial_numbers', e.messages) valid = False @@ -1630,7 +1634,7 @@ class StockItemCreate(AjaxCreateView): # If user has specified a range of serial numbers if len(sn) > 0: try: - serials = ExtractSerialNumbers(sn, quantity) + serials = extract_serial_numbers(sn, quantity) existing = part.find_conflicting_serial_numbers(serials) diff --git a/InvenTree/templates/js/build.js b/InvenTree/templates/js/build.js index d6f3681036..76e6093dc8 100644 --- a/InvenTree/templates/js/build.js +++ b/InvenTree/templates/js/build.js @@ -92,14 +92,14 @@ function makeBuildOutputActionButtons(output, buildInfo) { data: { output: outputId, }, - success: reloadTable, + reload: true, } ); }); $(panel).find(`#button-output-complete-${outputId}`).click(function() { launchModalForm( - `/build/${buildId}/complete/`, + `/build/${buildId}/complete-output/`, { success: reloadTable, data: {