Add forms / views for creating a new build output, and completing the build

- Also some refactoring of how forms are handled and saved
This commit is contained in:
Oliver Walters 2020-11-02 22:56:26 +11:00
parent b02c87ea50
commit 500da8099b
18 changed files with 495 additions and 521 deletions

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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',

View File

@ -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:

View File

@ -11,11 +11,19 @@ InvenTree | Allocate Parts
{% include "build/tabs.html" with tab='allocate' %}
<div class='btn-group' role='group'>
<h4>{% trans "Incomplete Build Ouputs" %}</h4>
<hr>
<div class='btn-group' role='group'>
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'>
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %}
</button>
<button class='btn btn-primary' type='button' id='btn-order-parts' title='{% trans "Order required parts" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
</button>
</div>
<h4>{% trans "Incomplete Build Ouputs" %}</h4>
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
{% 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 = `<div class='sub-table'><table class='table table-condensed table-striped' id='${subTableId}'></table></div>`;
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 = `<div class='btn-group float-right' role='group'>`;
{% 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 += `</div>`;
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 = `<div class='btn-group float-right' role='group'>`;
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 += '</div>';
return html;
},
}
{% endif %}
],
});
{% if build.status == BuildStatus.PENDING %}
$("#btn-allocate").on('click', function() {
launchModalForm(
@ -458,6 +84,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/", {
data: {

View File

@ -0,0 +1,20 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{% if build.part.has_trackable_parts %}
<div class='alert alert-block alert-warning'>
{% trans "The Bill of Materials contains trackable parts" %}<br>
{% trans "Build outputs must be generated individually." %}<br>
{% trans "Multiple build outputs will be created based on the quantity specified." %}
</div>
{% endif %}
{% if build.part.trackable %}
<div class='alert alert-block alert-info'>
{% trans "Trackable parts can have serial numbers specified" %}<br>
{% trans "Enter serial numbers to generate multiple single build outputs" %}
</div>
{% endif %}
{% endblock %}

View File

@ -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 %}

View File

@ -1,48 +1,23 @@
{% extends "modal_form.html" %}
{% load inventree_extras %}
{% load i18n %}
{% block pre_form_content %}
{% if fully_allocated %}
<div class='alert alert-block alert-info'>
<h4>{% trans "Stock allocation is complete" %}</h4>
{% if build.can_complete %}
<div class='alert alert-block alert-success'>
{% trans "Build can be completed" %}
</div>
{% else %}
<div class='alert alert-block alert-danger'>
<h4>{% trans "Stock allocation is incomplete" %}</h4>
<div class='panel-group'>
<div class='panel panel-default'>
<div class='panel panel-heading'>
<a data-toggle='collapse' href='#collapse-unallocated'>
{{ unallocated_parts|length }} {% trans "parts have not been fully allocated" %}
</a>
</div>
<div class='panel-collapse collapse' id='collapse-unallocated'>
<div class='panel-body'>
<ul class='list-group'>
{% for part in unallocated_parts %}
<li class='list-group-item'>
{% include "hover_image.html" with image=part.image %} {{ part }}
</li>
{% endfor %}
<b>{% trans "Build cannot be completed" %}</b><br>
<ul>
{% if build.incomplete_count > 0 %}
<li>{% trans "Incompleted build outputs remain" %}</li>
{% endif %}
{% if build.completed < build.quantity %}
<li>{% trans "Required build quantity has not been completed" %}</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class='panel panel-info'>
<div class='panel-heading'>
{% trans "The following items will be created" %}
</div>
<div class='panel-content'>
{% include "hover_image.html" with image=build.part.image hover=True %}
{% decimal output.quantity %} x {{ output.part.full_name }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,48 @@
{% extends "modal_form.html" %}
{% load inventree_extras %}
{% load i18n %}
{% block pre_form_content %}
{% if fully_allocated %}
<div class='alert alert-block alert-info'>
<h4>{% trans "Stock allocation is complete" %}</h4>
</div>
{% else %}
<div class='alert alert-block alert-danger'>
<h4>{% trans "Stock allocation is incomplete" %}</h4>
<div class='panel-group'>
<div class='panel panel-default'>
<div class='panel panel-heading'>
<a data-toggle='collapse' href='#collapse-unallocated'>
{{ unallocated_parts|length }} {% trans "parts have not been fully allocated" %}
</a>
</div>
<div class='panel-collapse collapse' id='collapse-unallocated'>
<div class='panel-body'>
<ul class='list-group'>
{% for part in unallocated_parts %}
<li class='list-group-item'>
{% include "hover_image.html" with image=part.image %} {{ part }}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class='panel panel-info'>
<div class='panel-heading'>
{% trans "The following items will be created" %}
</div>
<div class='panel-content'>
{% include "hover_image.html" with image=build.part.image hover=True %}
{% decimal output.quantity %} x {{ output.part.full_name }}
</div>
</div>
{% endblock %}

View File

@ -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)

View File

@ -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'),

View File

@ -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 {

View File

@ -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()

View File

@ -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.

View File

@ -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)

View File

@ -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)

View File

@ -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: {