-
{% trans "New Build Order" %}
+
+ {% trans "New Build Order" %}
@@ -40,12 +41,7 @@ InvenTree | {% trans "Build Orders" %}
$("#collapse-item-active").collapse().show();
$("#new-build").click(function() {
- launchModalForm(
- "{% url 'build-create' %}",
- {
- follow: true
- }
- );
+ newBuildOrder();
});
loadBuildTable($("#build-table"), {
diff --git a/InvenTree/build/templates/build/tabs.html b/InvenTree/build/templates/build/tabs.html
index ca2e92f290..2c45cd2361 100644
--- a/InvenTree/build/templates/build/tabs.html
+++ b/InvenTree/build/templates/build/tabs.html
@@ -5,7 +5,7 @@
{% trans "Details" %}
- {% trans "Allocated Parts" %}
+ {% trans "Allocate Parts" %}
{% trans "Build Outputs" %}{% if build.output_count > 0%}{{ build.output_count }} {% endif %}
@@ -13,4 +13,7 @@
{% trans "Notes" %}{% if build.notes %} {% endif %}
+
+ {% trans "Attachments" %}
+
\ No newline at end of file
diff --git a/InvenTree/build/templates/build/unallocate.html b/InvenTree/build/templates/build/unallocate.html
index f6543b3732..a650e95718 100644
--- a/InvenTree/build/templates/build/unallocate.html
+++ b/InvenTree/build/templates/build/unallocate.html
@@ -5,6 +5,11 @@
{{ block.super }}
-{% trans "Are you sure you wish to unallocate all stock for this build?" %}
+
+
+ {% trans "Are you sure you wish to unallocate all stock for this build?" %}
+
+ {% trans "All incomplete stock allocations will be removed from the build" %}
+
{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py
index e69853c269..b560a4f9c9 100644
--- a/InvenTree/build/test_build.py
+++ b/InvenTree/build/test_build.py
@@ -3,7 +3,6 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
-from django.db import transaction
from django.db.utils import IntegrityError
from build.models import Build, BuildItem
@@ -11,8 +10,6 @@ from stock.models import StockItem
from part.models import Part, BomItem
from InvenTree import status_codes as status
-from InvenTree.helpers import ExtractSerialNumbers
-
class BuildTest(TestCase):
"""
@@ -64,6 +61,21 @@ class BuildTest(TestCase):
quantity=10
)
+ # Create some build output (StockItem) objects
+ self.output_1 = StockItem.objects.create(
+ part=self.assembly,
+ quantity=5,
+ is_building=True,
+ build=self.build
+ )
+
+ self.output_2 = StockItem.objects.create(
+ part=self.assembly,
+ quantity=5,
+ is_building=True,
+ build=self.build,
+ )
+
# Create some stock items to assign to the build
self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=1000)
self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100)
@@ -73,23 +85,28 @@ class BuildTest(TestCase):
def test_init(self):
# Perform some basic tests before we start the ball rolling
- self.assertEqual(StockItem.objects.count(), 3)
+ self.assertEqual(StockItem.objects.count(), 5)
+
+ # Build is PENDING
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
- self.assertFalse(self.build.isFullyAllocated())
- self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1))
- self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2))
+ # Build has two build outputs
+ self.assertEqual(self.build.output_count, 2)
- self.assertEqual(self.build.getRequiredQuantity(self.sub_part_1), 100)
- self.assertEqual(self.build.getRequiredQuantity(self.sub_part_2), 250)
+ # None of the build outputs have been completed
+ for output in self.build.get_build_outputs().all():
+ self.assertFalse(self.build.isFullyAllocated(output))
+
+ self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
+ self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
+
+ self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 50)
+ self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 50)
+ self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 125)
+ self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 125)
- self.assertTrue(self.build.can_build)
self.assertFalse(self.build.is_complete)
- # Delete some stock and see if the build can still be completed
- self.stock_2_1.delete()
- self.assertFalse(self.build.can_build)
-
def test_build_item_clean(self):
# Ensure that dodgy BuildItem objects cannot be created
@@ -99,7 +116,7 @@ class BuildTest(TestCase):
b = BuildItem(stock_item=stock, build=self.build, quantity=10)
with self.assertRaises(ValidationError):
- b.clean()
+ b.save()
# Create a BuildItem which has too much stock assigned
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999)
@@ -113,6 +130,10 @@ class BuildTest(TestCase):
with self.assertRaises(ValidationError):
b.clean()
+ # Ok, what about we make one that does *not* fail?
+ b = BuildItem(stock_item=self.stock_1_1, build=self.build, install_into=self.output_1, quantity=10)
+ b.save()
+
def test_duplicate_bom_line(self):
# Try to add a duplicate BOM item - it should fail!
@@ -123,105 +144,145 @@ class BuildTest(TestCase):
quantity=99
)
- def allocate_stock(self, q11, q12, q21):
+ def allocate_stock(self, q11, q12, q21, output):
# Assign stock to this build
- BuildItem.objects.create(
- build=self.build,
- stock_item=self.stock_1_1,
- quantity=q11
- )
+ if q11 > 0:
+ BuildItem.objects.create(
+ build=self.build,
+ stock_item=self.stock_1_1,
+ quantity=q11,
+ install_into=output
+ )
- BuildItem.objects.create(
- build=self.build,
- stock_item=self.stock_1_2,
- quantity=q12
- )
+ if q12 > 0:
+ BuildItem.objects.create(
+ build=self.build,
+ stock_item=self.stock_1_2,
+ quantity=q12,
+ install_into=output
+ )
- BuildItem.objects.create(
- build=self.build,
- stock_item=self.stock_2_1,
- quantity=q21
- )
+ if q21 > 0:
+ BuildItem.objects.create(
+ build=self.build,
+ stock_item=self.stock_2_1,
+ quantity=q21,
+ install_into=output,
+ )
- with transaction.atomic():
- with self.assertRaises(IntegrityError):
- BuildItem.objects.create(
- build=self.build,
- stock_item=self.stock_2_1,
- quantity=99
- )
+ # Attempt to create another identical BuildItem
+ b = BuildItem(
+ build=self.build,
+ stock_item=self.stock_2_1,
+ quantity=q21
+ )
- self.assertEqual(BuildItem.objects.count(), 3)
+ with self.assertRaises(ValidationError):
+ b.clean()
def test_partial_allocation(self):
+ """
+ Partially allocate against output 1
+ """
- self.allocate_stock(50, 50, 200)
+ self.allocate_stock(50, 50, 200, self.output_1)
- self.assertFalse(self.build.isFullyAllocated())
- self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1))
- self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2))
+ self.assertTrue(self.build.isFullyAllocated(self.output_1))
+ self.assertFalse(self.build.isFullyAllocated(self.output_2))
+ self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
+ self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1))
+
+ self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_2))
+ self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
- self.build.unallocateStock()
+ # Check that the part has been allocated
+ self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 100)
+
+ self.build.unallocateStock(output=self.output_1)
self.assertEqual(BuildItem.objects.count(), 0)
- def test_auto_allocate(self):
+ # Check that the part has been unallocated
+ self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 0)
- allocations = self.build.getAutoAllocations()
+ def test_auto_allocate(self):
+ """
+ Test auto-allocation functionality against the build outputs
+ """
+
+ allocations = self.build.getAutoAllocations(self.output_1)
self.assertEqual(len(allocations), 1)
- self.build.autoAllocate()
+ self.build.autoAllocate(self.output_1)
self.assertEqual(BuildItem.objects.count(), 1)
- self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2))
+
+ # Check that one part has been fully allocated to the build output
+ self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1))
+
+ # But, the *other* build output has not been allocated against
+ self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
def test_cancel(self):
+ """
+ Test cancellation of the build
+ """
- self.allocate_stock(50, 50, 200)
+ # TODO
+
+ """
+ self.allocate_stock(50, 50, 200, self.output_1)
self.build.cancelBuild(None)
self.assertEqual(BuildItem.objects.count(), 0)
+ """
+ pass
def test_complete(self):
+ """
+ Test completion of a build output
+ """
- self.allocate_stock(50, 50, 250)
+ self.allocate_stock(50, 50, 250, self.output_1)
+ self.allocate_stock(50, 50, 250, self.output_2)
- self.assertTrue(self.build.isFullyAllocated())
+ self.assertTrue(self.build.isFullyAllocated(self.output_1))
+ self.assertTrue(self.build.isFullyAllocated(self.output_2))
- # Generate some serial numbers!
- serials = ExtractSerialNumbers("1-10", 10)
+ self.build.completeBuildOutput(self.output_1, None)
- self.build.completeBuild(None, serials, None)
+ self.assertFalse(self.build.can_complete)
+ self.build.completeBuildOutput(self.output_2, None)
+
+ self.assertTrue(self.build.can_complete)
+
+ self.build.complete_build(None)
+
self.assertEqual(self.build.status, status.BuildStatus.COMPLETE)
# the original BuildItem objects should have been deleted!
self.assertEqual(BuildItem.objects.count(), 0)
# New stock items should have been created!
- # - Ten for the build output (as the part was serialized)
- # - Three for the split items assigned to the build
- self.assertEqual(StockItem.objects.count(), 16)
+ self.assertEqual(StockItem.objects.count(), 4)
A = StockItem.objects.get(pk=self.stock_1_1.pk)
- B = StockItem.objects.get(pk=self.stock_1_2.pk)
+
+ # This stock item has been depleted!
+ with self.assertRaises(StockItem.DoesNotExist):
+ StockItem.objects.get(pk=self.stock_1_2.pk)
+
C = StockItem.objects.get(pk=self.stock_2_1.pk)
# Stock should have been subtracted from the original items
- self.assertEqual(A.quantity, 950)
- self.assertEqual(B.quantity, 50)
- self.assertEqual(C.quantity, 4750)
-
- # New stock items should have also been allocated to the build
- allocated = StockItem.objects.filter(build_order=self.build)
-
- self.assertEqual(allocated.count(), 3)
-
- q = sum([item.quantity for item in allocated.all()])
-
- self.assertEqual(q, 350)
+ self.assertEqual(A.quantity, 900)
+ self.assertEqual(C.quantity, 4500)
# And 10 new stock items created for the build output
outputs = StockItem.objects.filter(build=self.build)
- self.assertEqual(outputs.count(), 10)
+ self.assertEqual(outputs.count(), 2)
+
+ for output in outputs:
+ self.assertFalse(output.is_building)
diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py
index ded98a441c..f01aaa83c9 100644
--- a/InvenTree/build/tests.py
+++ b/InvenTree/build/tests.py
@@ -12,6 +12,7 @@ from rest_framework import status
import json
from .models import Build
+from stock.models import StockItem
from InvenTree.status_codes import BuildStatus
@@ -49,7 +50,7 @@ class BuildTestSimple(TestCase):
def test_build_objects(self):
# Ensure the Build objects were correctly created
- self.assertEqual(Build.objects.count(), 2)
+ self.assertEqual(Build.objects.count(), 5)
b = Build.objects.get(pk=2)
self.assertEqual(b.batch, 'B2')
self.assertEqual(b.quantity, 21)
@@ -127,11 +128,37 @@ class TestBuildAPI(APITestCase):
self.client.login(username='testuser', password='password')
def test_get_build_list(self):
- """ Test that we can retrieve list of build objects """
+ """
+ Test that we can retrieve list of build objects
+ """
+
url = reverse('api-build-list')
response = self.client.get(url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(len(response.data), 5)
+
+ # Filter query by build status
+ response = self.client.get(url, {'status': 40}, format='json')
+
+ self.assertEqual(len(response.data), 4)
+
+ # Filter by "active" status
+ response = self.client.get(url, {'active': True}, format='json')
+ self.assertEqual(len(response.data), 1)
+ self.assertEqual(response.data[0]['pk'], 1)
+
+ response = self.client.get(url, {'active': False}, format='json')
+ self.assertEqual(len(response.data), 4)
+
+ # Filter by 'part' status
+ response = self.client.get(url, {'part': 25}, format='json')
+ self.assertEqual(len(response.data), 2)
+
+ # Filter by an invalid part
+ response = self.client.get(url, {'part': 99999}, format='json')
+ self.assertEqual(len(response.data), 0)
+
def test_get_build_item_list(self):
""" Test that we can retrieve list of BuildItem objects """
url = reverse('api-build-item-list')
@@ -176,6 +203,16 @@ class TestBuildViews(TestCase):
self.client.login(username='username', password='password')
+ # Create a build output for build # 1
+ self.build = Build.objects.get(pk=1)
+
+ self.output = StockItem.objects.create(
+ part=self.build.part,
+ quantity=self.build.quantity,
+ build=self.build,
+ is_building=True,
+ )
+
def test_build_index(self):
""" test build index view """
@@ -254,10 +291,15 @@ class TestBuildViews(TestCase):
# url = reverse('build-item-edit')
pass
- def test_build_complete(self):
- """ Test the build completion form """
+ def test_build_output_complete(self):
+ """
+ Test the build output completion form
+ """
- url = reverse('build-complete', args=(1,))
+ # Firstly, check that the build cannot be completed!
+ self.assertFalse(self.build.can_complete)
+
+ url = reverse('build-output-complete', args=(1,))
# Test without confirmation
response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
@@ -267,12 +309,26 @@ class TestBuildViews(TestCase):
self.assertFalse(data['form_valid'])
# Test with confirmation, valid location
- response = self.client.post(url, {'confirm': 1, 'location': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+ response = self.client.post(
+ url,
+ {
+ 'confirm': 1,
+ 'confirm_incomplete': 1,
+ 'location': 1,
+ 'output': self.output.pk,
+ },
+ HTTP_X_REQUESTED_WITH='XMLHttpRequest'
+ )
+
self.assertEqual(response.status_code, 200)
-
+
data = json.loads(response.content)
self.assertTrue(data['form_valid'])
+ # Now the build should be able to be completed
+ self.build.refresh_from_db()
+ self.assertTrue(self.build.can_complete)
+
# Test with confirmation, invalid location
response = self.client.post(url, {'confirm': 1, 'location': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py
index d8cb3c03ea..08142e6939 100644
--- a/InvenTree/build/urls.py
+++ b/InvenTree/build/urls.py
@@ -11,12 +11,16 @@ 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'^complete/?', views.BuildComplete.as_view(), name='build-complete'),
+ 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-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'),
+ url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'),
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
@@ -31,6 +35,12 @@ build_urls = [
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
])),
+ url('^attachment/', include([
+ url('^new/', views.BuildAttachmentCreate.as_view(), name='build-attachment-create'),
+ url(r'^(?P
\d+)/edit/', views.BuildAttachmentEdit.as_view(), name='build-attachment-edit'),
+ url(r'^(?P\d+)/delete/', views.BuildAttachmentDelete.as_view(), name='build-attachment-delete'),
+ ])),
+
url(r'new/', views.BuildCreate.as_view(), name='build-create'),
url(r'^(?P\d+)/', include(build_detail_urls)),
diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index 1296e42fae..5dd36a6871 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -12,13 +12,13 @@ from django.forms import HiddenInput
from django.urls import reverse
from part.models import Part
-from .models import Build, BuildItem
+from .models import Build, BuildItem, BuildOrderAttachment
from . import forms
from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin
-from InvenTree.helpers import str2bool, ExtractSerialNumbers
+from InvenTree.helpers import str2bool, extract_serial_numbers, normalize
from InvenTree.status_codes import BuildStatus
@@ -60,30 +60,25 @@ class BuildCancel(AjaxUpdateView):
form_class = forms.CancelBuildForm
role_required = 'build.change'
- def post(self, request, *args, **kwargs):
- """ Handle POST request. Mark the build status as CANCELLED """
+ def validate(self, build, form, **kwargs):
- build = self.get_object()
+ confirm = str2bool(form.cleaned_data.get('confirm_cancel', False))
- form = self.get_form()
+ if not confirm:
+ form.add_error('confirm_cancel', _('Confirm build cancellation'))
- valid = form.is_valid()
+ def save(self, build, form, **kwargs):
+ """
+ Cancel the build.
+ """
- confirm = str2bool(request.POST.get('confirm_cancel', False))
+ build.cancelBuild(self.request.user)
- if confirm:
- build.cancelBuild(request.user)
- else:
- form.errors['confirm_cancel'] = [_('Confirm build cancellation')]
- valid = False
-
- data = {
- 'form_valid': valid,
+ def get_data(self):
+ return {
'danger': _('Build was cancelled')
}
- return self.renderJsonResponse(request, form, data=data)
-
class BuildAutoAllocate(AjaxUpdateView):
""" View to auto-allocate parts for a build.
@@ -93,52 +88,246 @@ class BuildAutoAllocate(AjaxUpdateView):
"""
model = Build
- form_class = forms.ConfirmBuildForm
+ form_class = forms.AutoAllocateForm
context_object_name = 'build'
ajax_form_title = _('Allocate Stock')
ajax_template_name = 'build/auto_allocate.html'
role_required = 'build.change'
+ def get_initial(self):
+ """
+ Initial values for the form.
+ """
+
+ initials = super().get_initial()
+
+ # Pointing to a particular build output?
+ output = self.get_param('output')
+
+ if output:
+ try:
+ output = StockItem.objects.get(pk=output)
+ initials['output'] = output
+ except (ValueError, StockItem.DoesNotExist):
+ pass
+
+ return initials
+
def get_context_data(self, *args, **kwargs):
- """ Get the context data for form rendering. """
+ """
+ Get the context data for form rendering.
+ """
context = {}
+ build = self.get_object()
+
+ form = self.get_form()
+
+ output_id = form['output'].value()
+
try:
- build = Build.objects.get(id=self.kwargs['pk'])
- context['build'] = build
- context['allocations'] = build.getAutoAllocations()
- except Build.DoesNotExist:
- context['error'] = _('No matching build found')
+ output = StockItem.objects.get(pk=output_id)
+ except (ValueError, StockItem.DoesNotExist):
+ output = None
+
+ if output:
+ context['output'] = output
+ context['allocations'] = build.getAutoAllocations(output)
+
+ context['build'] = build
return context
- def post(self, request, *args, **kwargs):
- """ Handle POST request. Perform auto allocations.
+ def get_form(self):
- - If the form validation passes, perform allocations
- - Otherwise, the form is passed back to the client
+ form = super().get_form()
+
+ if form['output'].value():
+ # Hide the 'output' field
+ form.fields['output'].widget = HiddenInput()
+
+ return form
+
+ def validate(self, build, form, **kwargs):
+
+ output = form.cleaned_data.get('output', None)
+
+ if not output:
+ form.add_error(None, _('Build output must be specified'))
+
+ def save(self, build, form, **kwargs):
+ """
+ Once the form has been validated,
+ perform auto-allocations
"""
- build = self.get_object()
- form = self.get_form()
+ output = form.cleaned_data.get('output', None)
- confirm = request.POST.get('confirm', False)
+ build.autoAllocate(output)
- valid = False
-
- if confirm is False:
- form.errors['confirm'] = [_('Confirm stock allocation')]
- form.non_field_errors = [_('Check the confirmation box at the bottom of the list')]
- else:
- build.autoAllocate()
- valid = True
-
- data = {
- 'form_valid': valid,
+ def get_data(self):
+ return {
+ 'success': _('Allocated stock to build output'),
}
- return self.renderJsonResponse(request, form, data, context=self.get_context_data())
+
+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):
+
+ build = self.get_object()
+ part = build.part
+
+ context = self.get_form_kwargs()
+
+ # Pass the 'part' through to the form,
+ # so we can add the next serial number as a placeholder
+ context['build'] = build
+
+ form = self.form_class(**context)
+
+ # If the part is not trackable, hide the serial number input
+ if not part.trackable:
+ form.fields['serial_numbers'].widget = HiddenInput()
+
+ return form
+
+
+class BuildOutputDelete(AjaxUpdateView):
+ """
+ Delete a build output (StockItem) for a given build.
+
+ Form is a simple confirmation dialog
+ """
+
+ model = Build
+ form_class = forms.BuildOutputDeleteForm
+ ajax_form_title = _('Delete Build Output')
+ role_required = 'build.delete'
+
+ def get_initial(self):
+
+ initials = super().get_initial()
+
+ output = self.get_param('output')
+
+ initials['output_id'] = output
+
+ return initials
+
+ def validate(self, build, form, **kwargs):
+
+ data = form.cleaned_data
+
+ confirm = data.get('confirm', False)
+
+ if not confirm:
+ form.add_error('confirm', _('Confirm unallocation of build stock'))
+ form.add_error(None, _('Check the confirmation box'))
+
+ output_id = data.get('output_id', None)
+ output = None
+
+ try:
+ output = StockItem.objects.get(pk=output_id)
+ except (ValueError, StockItem.DoesNotExist):
+ pass
+
+ if output:
+ if not output.build == build:
+ form.add_error(None, _('Build output does not match build'))
+ else:
+ form.add_error(None, _('Build output must be specified'))
+
+ def save(self, build, form, **kwargs):
+
+ output_id = form.cleaned_data.get('output_id')
+
+ output = StockItem.objects.get(pk=output_id)
+
+ build.deleteBuildOutput(output)
+
+ def get_data(self):
+ return {
+ 'danger': _('Build output deleted'),
+ }
class BuildUnallocate(AjaxUpdateView):
@@ -148,10 +337,28 @@ class BuildUnallocate(AjaxUpdateView):
"""
model = Build
- form_class = forms.ConfirmBuildForm
+ form_class = forms.UnallocateBuildForm
ajax_form_title = _("Unallocate Stock")
ajax_template_name = "build/unallocate.html"
- form_required = 'build.change'
+ role_required = 'build.change'
+
+ def get_initial(self):
+
+ initials = super().get_initial()
+
+ # Pointing to a particular build output?
+ output = self.get_param('output')
+
+ if output:
+ initials['output_id'] = output
+
+ # Pointing to a particular part?
+ part = self.get_param('part')
+
+ if part:
+ initials['part_id'] = part
+
+ return initials
def post(self, request, *args, **kwargs):
@@ -160,13 +367,27 @@ class BuildUnallocate(AjaxUpdateView):
confirm = request.POST.get('confirm', False)
+ output_id = request.POST.get('output_id', None)
+
+ try:
+ output = StockItem.objects.get(pk=output_id)
+ except (ValueError, StockItem.DoesNotExist):
+ output = None
+
+ part_id = request.POST.get('part_id', None)
+
+ try:
+ part = Part.objects.get(pk=part_id)
+ except (ValueError, Part.DoesNotExist):
+ part = None
+
valid = False
if confirm is False:
- form.errors['confirm'] = [_('Confirm unallocation of build stock')]
- form.non_field_errors = [_('Check the confirmation box')]
+ form.add_error('confirm', _('Confirm unallocation of build stock'))
+ form.add_error(None, _('Check the confirmation box'))
else:
- build.unallocateStock()
+ build.unallocateStock(output=output, part=part)
valid = True
data = {
@@ -177,7 +398,41 @@ 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'
+
+ def validate(self, build, form, **kwargs):
+
+ if not build.can_complete:
+ form.add_error(None, _('Build order cannot be completed'))
+
+ def save(self, build, form, **kwargs):
+ """
+ Perform the build completion step
+ """
+
+ build.complete_build(self.request.user)
+
+ def get_data(self):
+ return {
+ 'success': _('Completed build order')
+ }
+
+
+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
@@ -185,39 +440,62 @@ class BuildComplete(AjaxUpdateView):
"""
model = Build
- form_class = forms.CompleteBuildForm
+ form_class = forms.CompleteBuildOutputForm
context_object_name = "build"
- ajax_form_title = _("Complete Build")
- ajax_template_name = "build/complete.html"
+ ajax_form_title = _("Complete Build Output")
+ ajax_template_name = "build/complete_output.html"
role_required = 'build.change'
def get_form(self):
- """ Get the form object.
-
- If the part is trackable, include a field for serial numbers.
- """
+
build = self.get_object()
form = super().get_form()
- if not build.part.trackable:
- form.fields.pop('serial_numbers')
- else:
+ # Extract the build output object
+ output = None
+ output_id = form['output'].value()
- form.field_placeholder['serial_numbers'] = build.part.getSerialNumberString(build.quantity)
+ try:
+ output = StockItem.objects.get(pk=output_id)
+ except (ValueError, StockItem.DoesNotExist):
+ pass
- form.rebuild_layout()
+ if output:
+ if build.isFullyAllocated(output):
+ form.fields['confirm_incomplete'].widget = HiddenInput()
return form
+ def validate(self, build, form, **kwargs):
+
+ data = form.cleaned_data
+
+ output = data.get('output', None)
+
+ if output:
+
+ quantity = data.get('quantity', None)
+
+ if quantity and quantity > output.quantity:
+ form.add_error('quantity', _('Quantity to complete cannot exceed build output quantity'))
+
+ if not build.isFullyAllocated(output):
+ confirm = str2bool(data.get('confirm_incomplete', False))
+
+ if not confirm:
+ form.add_error('confirm_incomplete', _('Confirm completion of incomplete build'))
+
+ else:
+ form.add_error(None, _('Build output must be specified'))
+
def get_initial(self):
""" Get initial form data for the CompleteBuild form
- If the part being built has a default location, pre-select that location
"""
- initials = super(BuildComplete, self).get_initial().copy()
-
+ initials = super().get_initial()
build = self.get_object()
if build.part.default_location is not None:
@@ -227,100 +505,77 @@ class BuildComplete(AjaxUpdateView):
except StockLocation.DoesNotExist:
pass
+ output = self.get_param('output', None)
+
+ if output:
+ try:
+ output = StockItem.objects.get(pk=output)
+ except (ValueError, StockItem.DoesNotExist):
+ output = None
+
+ # Output has not been supplied? Try to "guess"
+ if not output:
+
+ incomplete = build.get_build_outputs(complete=False)
+
+ if incomplete.count() == 1:
+ output = incomplete[0]
+
+ if output is not None:
+ initials['output'] = output
+
+ initials['location'] = build.destination
+
return initials
def get_context_data(self, **kwargs):
- """ Get context data for passing to the rendered form
+ """
+ Get context data for passing to the rendered form
- Build information is required
"""
- build = Build.objects.get(id=self.kwargs['pk'])
+ build = self.get_object()
context = {}
# Build object
context['build'] = build
- # Items to be removed from stock
- taking = BuildItem.objects.filter(build=build.id)
- context['taking'] = taking
-
- return context
-
- def post(self, request, *args, **kwargs):
- """ Handle POST request. Mark the build as COMPLETE
-
- - If the form validation passes, the Build objects completeBuild() method is called
- - Otherwise, the form is passed back to the client
- """
-
- build = self.get_object()
-
form = self.get_form()
- confirm = str2bool(request.POST.get('confirm', False))
+ output = form['output'].value()
- loc_id = request.POST.get('location', None)
-
- valid = False
-
- if confirm is False:
- form.errors['confirm'] = [
- _('Confirm completion of build'),
- ]
- else:
+ if output:
try:
- location = StockLocation.objects.get(id=loc_id)
- valid = True
- except (ValueError, StockLocation.DoesNotExist):
- form.errors['location'] = [_('Invalid location selected')]
+ output = StockItem.objects.get(pk=output)
+ context['output'] = output
+ context['fully_allocated'] = build.isFullyAllocated(output)
+ context['allocated_parts'] = build.allocatedParts(output)
+ context['unallocated_parts'] = build.unallocatedParts(output)
+ except (ValueError, StockItem.DoesNotExist):
+ pass
- serials = []
+ return context
- if build.part.trackable:
- # A build for a trackable part may optionally specify serial numbers.
+ def save(self, build, form, **kwargs):
- sn = request.POST.get('serial_numbers', '')
+ data = form.cleaned_data
- sn = str(sn).strip()
-
- # If the user has specified serial numbers, check they are valid
- if len(sn) > 0:
- try:
- # Exctract a list of provided serial numbers
- serials = ExtractSerialNumbers(sn, build.quantity)
-
- existing = []
-
- for serial in serials:
- if build.part.checkIfSerialNumberExists(serial):
- existing.append(serial)
-
- if len(existing) > 0:
- exists = ",".join([str(x) for x in existing])
- form.errors['serial_numbers'] = [_('The following serial numbers already exist: ({sn})'.format(sn=exists))]
- valid = False
-
- except ValidationError as e:
- form.errors['serial_numbers'] = e.messages
- valid = False
-
- if valid:
- if not build.completeBuild(location, serials, request.user):
- form.non_field_errors = [('Build could not be completed')]
- valid = False
-
- data = {
- 'form_valid': valid,
- }
-
- return self.renderJsonResponse(request, form, data, context=self.get_context_data())
+ location = data.get('location', None)
+ output = data.get('output', None)
+ # Complete the build output
+ build.completeBuildOutput(
+ output,
+ self.request.user,
+ location=location,
+ )
+
def get_data(self):
""" Provide feedback data back to the form """
return {
- 'info': _('Build marked as COMPLETE')
+ 'success': _('Build output completed')
}
@@ -404,6 +659,14 @@ class BuildCreate(AjaxCreateView):
ajax_template_name = 'modal_form.html'
role_required = 'build.add'
+ def get_form(self):
+ form = super().get_form()
+
+ if form['part'].value():
+ form.fields['part'].widget = HiddenInput()
+
+ return form
+
def get_initial(self):
""" Get initial parameters for Build creation.
@@ -412,8 +675,17 @@ class BuildCreate(AjaxCreateView):
initials = super(BuildCreate, self).get_initial().copy()
- # User has provided a Part ID
- initials['part'] = self.request.GET.get('part', None)
+ part = self.request.GET.get('part', None)
+
+ if part:
+
+ try:
+ part = Part.objects.get(pk=part)
+ # User has provided a Part ID
+ initials['part'] = part
+ initials['destination'] = part.get_default_location()
+ except (ValueError, Part.DoesNotExist):
+ pass
initials['reference'] = Build.getNextBuildNumber()
@@ -431,6 +703,17 @@ class BuildCreate(AjaxCreateView):
'success': _('Created new build'),
}
+ def validate(self, build, form, **kwargs):
+ """
+ Perform extra form validation.
+
+ - If part is trackable, check that either batch or serial numbers are calculated
+
+ By this point form.is_valid() has been executed
+ """
+
+ pass
+
class BuildUpdate(AjaxUpdateView):
""" View for editing a Build object """
@@ -442,6 +725,34 @@ class BuildUpdate(AjaxUpdateView):
ajax_template_name = 'modal_form.html'
role_required = 'build.change'
+ def get_form(self):
+
+ form = super().get_form()
+
+ build = self.get_object()
+
+ # Fields which are included in the form, but hidden
+ hidden = [
+ 'parent',
+ 'sales_order',
+ ]
+
+ if build.is_complete:
+ # Fields which cannot be edited once the build has been completed
+
+ hidden += [
+ 'part',
+ 'quantity',
+ 'batch',
+ 'take_from',
+ 'destination',
+ ]
+
+ for field in hidden:
+ form.fields[field].widget = HiddenInput()
+
+ return form
+
def get_data(self):
return {
'info': _('Edited build'),
@@ -475,23 +786,37 @@ class BuildItemDelete(AjaxDeleteView):
class BuildItemCreate(AjaxCreateView):
- """ View for allocating a new part to a build """
+ """
+ View for allocating a StockItem to a build output.
+ """
model = BuildItem
form_class = forms.EditBuildItemForm
ajax_template_name = 'build/create_build_item.html'
- ajax_form_title = _('Allocate new Part')
+ ajax_form_title = _('Allocate stock to build output')
role_required = 'build.add'
+ # The output StockItem against which the allocation is being made
+ output = None
+
+ # The "part" which is being allocated to the output
part = None
+
available_stock = None
def get_context_data(self):
- ctx = super(AjaxCreateView, self).get_context_data()
+ """
+ Provide context data to the template which renders the form.
+ """
+
+ ctx = super().get_context_data()
if self.part:
ctx['part'] = self.part
+ if self.output:
+ ctx['output'] = self.output
+
if self.available_stock:
ctx['stock'] = self.available_stock
else:
@@ -499,73 +824,108 @@ class BuildItemCreate(AjaxCreateView):
return ctx
+ def validate(self, build_item, form, **kwargs):
+ """
+ Extra validation steps as required
+ """
+
+ data = form.cleaned_data
+
+ stock_item = data.get('stock_item', None)
+ quantity = data.get('quantity', None)
+
+ if stock_item:
+ # Stock item must actually be in stock!
+ if not stock_item.in_stock:
+ form.add_error('stock_item', _('Item must be currently in stock'))
+
+ # Check that there are enough items available
+ if quantity is not None:
+ available = stock_item.unallocated_quantity()
+ if quantity > available:
+ form.add_error('stock_item', _('Stock item is over-allocated'))
+ form.add_error('quantity', _('Available') + ': ' + str(normalize(available)))
+ else:
+ form.add_error('stock_item', _('Stock item must be selected'))
+
def get_form(self):
""" Create Form for making / editing new Part object """
form = super(AjaxCreateView, self).get_form()
+ self.build = None
+ self.part = None
+ self.output = None
+
# If the Build object is specified, hide the input field.
# We do not want the users to be able to move a BuildItem to a different build
build_id = form['build'].value()
if build_id is not None:
+ """
+ If the build has been provided, hide the widget to change the build selection.
+ Additionally, update the allowable selections for other fields.
+ """
form.fields['build'].widget = HiddenInput()
+ form.fields['install_into'].queryset = StockItem.objects.filter(build=build_id, is_building=True)
+ self.build = Build.objects.get(pk=build_id)
+ else:
+ """
+ Build has *not* been selected
+ """
+ pass
# If the sub_part is supplied, limit to matching stock items
- part_id = self.get_param('part')
+ part_id = form['part_id'].value()
if part_id:
try:
self.part = Part.objects.get(pk=part_id)
-
- query = form.fields['stock_item'].queryset
-
- # Only allow StockItem objects which match the current part
- query = query.filter(part=part_id)
-
- if build_id is not None:
- try:
- build = Build.objects.get(id=build_id)
-
- if build.take_from is not None:
- # Limit query to stock items that are downstream of the 'take_from' location
- query = query.filter(location__in=[loc for loc in build.take_from.getUniqueChildren()])
-
- except Build.DoesNotExist:
- pass
-
- # Exclude StockItem objects which are already allocated to this build and part
- query = query.exclude(id__in=[item.stock_item.id for item in BuildItem.objects.filter(build=build_id, stock_item__part=part_id)])
-
- form.fields['stock_item'].queryset = query
-
- stocks = query.all()
- self.available_stock = stocks
-
- # If there is only one item selected, select it
- if len(stocks) == 1:
- form.fields['stock_item'].initial = stocks[0].id
- # There is no stock available
- elif len(stocks) == 0:
- # TODO - Add a message to the form describing the problem
- pass
-
- except Part.DoesNotExist:
- self.part = None
+
+ except (ValueError, Part.DoesNotExist):
pass
+ # If the output stock item is specified, hide the input field
+ output_id = form['install_into'].value()
+
+ if output_id is not None:
+
+ try:
+ self.output = StockItem.objects.get(pk=output_id)
+ form.fields['install_into'].widget = HiddenInput()
+ except (ValueError, StockItem.DoesNotExist):
+ pass
+
+ else:
+ # If the output is not specified, but we know that the part is non-trackable, hide the install_into field
+ if self.part and not self.part.trackable:
+ form.fields['install_into'].widget = HiddenInput()
+
+ if self.build and self.part:
+ available_items = self.build.availableStockItems(self.part, self.output)
+ form.fields['stock_item'].queryset = available_items
+
+ self.available_stock = form.fields['stock_item'].queryset.all()
+
+ # If there is only a single stockitem available, select it!
+ if len(self.available_stock) == 1:
+ form.fields['stock_item'].initial = self.available_stock[0].pk
+
return form
def get_initial(self):
""" Provide initial data for BomItem. Look for the folllowing in the GET data:
- build: pk of the Build object
+ - part: pk of the Part object which we are assigning
+ - output: pk of the StockItem object into which the allocated stock will be installed
"""
initials = super(AjaxCreateView, self).get_initial().copy()
build_id = self.get_param('build')
part_id = self.get_param('part')
+ output_id = self.get_param('install_into')
# Reference to a Part object
part = None
@@ -576,10 +936,13 @@ class BuildItemCreate(AjaxCreateView):
# Reference to a Build object
build = None
+ # Reference to a StockItem object
+ output = None
+
if part_id:
try:
part = Part.objects.get(pk=part_id)
- initials['part'] = part
+ initials['part_id'] = part.pk
except Part.DoesNotExist:
pass
@@ -590,15 +953,26 @@ class BuildItemCreate(AjaxCreateView):
except Build.DoesNotExist:
pass
+ # If the output has been specified
+ if output_id:
+ try:
+ output = StockItem.objects.get(pk=output_id)
+ initials['install_into'] = output
+ except (ValueError, StockItem.DoesNotExist):
+ pass
+
+ # Work out how much stock is required
+ if build and part:
+ required_quantity = build.unallocatedQuantity(part, output)
+ else:
+ required_quantity = None
+
quantity = self.request.GET.get('quantity', None)
if quantity is not None:
quantity = float(quantity)
-
- if quantity is None:
- # Work out how many parts remain to be alloacted for the build
- if part:
- quantity = build.getUnallocatedQuantity(part)
+ elif required_quantity is not None:
+ quantity = required_quantity
item_id = self.get_param('item')
@@ -606,7 +980,7 @@ class BuildItemCreate(AjaxCreateView):
if item_id:
try:
item = StockItem.objects.get(pk=item_id)
- except:
+ except (ValueError, StockItem.DoesNotExist):
pass
# If a StockItem is not selected, try to auto-select one
@@ -632,7 +1006,7 @@ class BuildItemEdit(AjaxUpdateView):
""" View to edit a BuildItem object """
model = BuildItem
- ajax_template_name = 'modal_form.html'
+ ajax_template_name = 'build/edit_build_item.html'
form_class = forms.EditBuildItemForm
ajax_form_title = _('Edit Stock Allocation')
role_required = 'build.change'
@@ -643,23 +1017,107 @@ class BuildItemEdit(AjaxUpdateView):
}
def get_form(self):
- """ Create form for editing a BuildItem.
+ """
+ Create form for editing a BuildItem.
- Limit the StockItem options to items that match the part
"""
- build_item = self.get_object()
-
form = super(BuildItemEdit, self).get_form()
- query = StockItem.objects.all()
-
- if build_item.stock_item:
- part_id = build_item.stock_item.part.id
- query = query.filter(part=part_id)
+ # Hide fields which we do not wish the user to edit
+ for field in ['build', 'stock_item']:
+ if form[field].value():
+ form.fields[field].widget = HiddenInput()
- form.fields['stock_item'].queryset = query
+ form.fields['install_into'].widget = HiddenInput()
+ return form
+
+
+class BuildAttachmentCreate(AjaxCreateView):
+ """
+ View for creating a BuildAttachment
+ """
+
+ model = BuildOrderAttachment
+ form_class = forms.EditBuildAttachmentForm
+ ajax_form_title = _('Add Build Order Attachment')
+ role_required = 'build.add'
+
+ 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 {
+ 'success': _('Added attachment')
+ }
+
+ def get_initial(self):
+ """
+ Get initial data for creating an attachment
+ """
+
+ initials = super().get_initial()
+
+ try:
+ initials['build'] = Build.objects.get(pk=self.request.GET.get('build', -1))
+ except (ValueError, Build.DoesNotExist):
+ pass
+
+ return initials
+
+ def get_form(self):
+ """
+ Hide the 'build' field if specified
+ """
+
+ form = super().get_form()
+
+ form.fields['build'].widget = HiddenInput()
+
+ return form
+
+
+class BuildAttachmentEdit(AjaxUpdateView):
+ """
+ View for editing a BuildAttachment object
+ """
+
+ model = BuildOrderAttachment
+ form_class = forms.EditBuildAttachmentForm
+ ajax_form_title = _('Edit Attachment')
+ role_required = 'build.change'
+
+ def get_form(self):
+ form = super().get_form()
form.fields['build'].widget = HiddenInput()
return form
+
+ def get_data(self):
+ return {
+ 'success': _('Attachment updated')
+ }
+
+
+class BuildAttachmentDelete(AjaxDeleteView):
+ """
+ View for deleting a BuildAttachment
+ """
+
+ model = BuildOrderAttachment
+ ajax_form_title = _('Delete Attachment')
+ context_object_name = 'attachment'
+ role_required = 'build.delete'
+
+ def get_data(self):
+ return {
+ 'danger': _('Deleted attachment')
+ }
diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py
index 548ac96016..731fd193fa 100644
--- a/InvenTree/company/api.py
+++ b/InvenTree/company/api.py
@@ -131,6 +131,13 @@ class SupplierPartList(generics.ListCreateAPIView):
if part is not None:
queryset = queryset.filter(part=part)
+ # Filter by 'active' status of the part?
+ active = params.get('active', None)
+
+ if active is not None:
+ active = str2bool(active)
+ queryset = queryset.filter(part__active=active)
+
return queryset
def get_serializer(self, *args, **kwargs):
diff --git a/InvenTree/company/templates/company/assigned_stock.html b/InvenTree/company/templates/company/assigned_stock.html
index 392dcaa999..b1a98a155a 100644
--- a/InvenTree/company/templates/company/assigned_stock.html
+++ b/InvenTree/company/templates/company/assigned_stock.html
@@ -15,7 +15,7 @@