InvenTree/InvenTree/build/views.py
2021-07-15 23:06:37 +10:00

894 lines
25 KiB
Python

"""
Django views for interacting with Build objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError
from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
from part.models import Part
from .models import Build, BuildItem
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, extract_serial_numbers, normalize, isNull
from InvenTree.status_codes import BuildStatus, StockStatus
class BuildIndex(InvenTreeRoleMixin, ListView):
""" View for displaying list of Builds
"""
model = Build
template_name = 'build/index.html'
context_object_name = 'builds'
def get_queryset(self):
""" Return all Build objects (order by date, newest first) """
return Build.objects.order_by('status', '-completion_date')
def get_context_data(self, **kwargs):
context = super(BuildIndex, self).get_context_data(**kwargs).copy()
context['BuildStatus'] = BuildStatus
context['active'] = self.get_queryset().filter(status__in=BuildStatus.ACTIVE_CODES)
context['completed'] = self.get_queryset().filter(status=BuildStatus.COMPLETE)
context['cancelled'] = self.get_queryset().filter(status=BuildStatus.CANCELLED)
return context
class BuildCancel(AjaxUpdateView):
""" View to cancel a Build.
Provides a cancellation information dialog
"""
model = Build
ajax_template_name = 'build/cancel.html'
ajax_form_title = _('Cancel Build')
context_object_name = 'build'
form_class = forms.CancelBuildForm
def validate(self, build, form, **kwargs):
confirm = str2bool(form.cleaned_data.get('confirm_cancel', False))
if not confirm:
form.add_error('confirm_cancel', _('Confirm build cancellation'))
def save(self, build, form, **kwargs):
"""
Cancel the build.
"""
build.cancelBuild(self.request.user)
def get_data(self):
return {
'danger': _('Build was cancelled')
}
class BuildAutoAllocate(AjaxUpdateView):
""" View to auto-allocate parts for a build.
Follows a simple set of rules to automatically allocate StockItem objects.
Ref: build.models.Build.getAutoAllocations()
"""
model = Build
form_class = forms.AutoAllocateForm
context_object_name = 'build'
ajax_form_title = _('Allocate Stock')
ajax_template_name = 'build/auto_allocate.html'
def get_initial(self):
"""
Initial values for the form.
"""
initials = super().get_initial()
return initials
def get_context_data(self, *args, **kwargs):
"""
Get the context data for form rendering.
"""
context = {}
build = self.get_object()
context['allocations'] = build.getAutoAllocations()
context['build'] = build
return context
def get_form(self):
form = super().get_form()
return form
def validate(self, build, form, **kwargs):
pass
def save(self, build, form, **kwargs):
"""
Once the form has been validated,
perform auto-allocations
"""
build.autoAllocate()
def get_data(self):
return {
'success': _('Allocated stock to build output'),
}
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')
def validate(self, build, form, **kwargs):
"""
Validation for the form:
"""
quantity = form.cleaned_data.get('output_quantity', None)
serials = form.cleaned_data.get('serial_numbers', None)
if quantity:
build = self.get_object()
# Check that requested output don't exceed build remaining quantity
maximum_output = int(build.remaining - build.incomplete_count)
if quantity > maximum_output:
form.add_error(
'output_quantity',
_('Maximum output quantity is ') + str(maximum_output),
)
# 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('output_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['output_quantity'] = int(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):
""" View to un-allocate all parts from a build.
Provides a simple confirmation dialog with a BooleanField checkbox.
"""
model = Build
form_class = forms.UnallocateBuildForm
ajax_form_title = _("Unallocate Stock")
ajax_template_name = "build/unallocate.html"
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):
build = self.get_object()
form = self.get_form()
confirm = request.POST.get('confirm', False)
output_id = request.POST.get('output_id', None)
if output_id:
# If a "null" output is provided, we are trying to unallocate "untracked" stock
if isNull(output_id):
output = None
else:
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.add_error('confirm', _('Confirm unallocation of build stock'))
form.add_error(None, _('Check the confirmation box'))
else:
valid = True
# Unallocate the entire build
if not output_id:
build.unallocateAll()
# Unallocate a single output
elif output:
build.unallocateOutput(output, part=part)
# Unallocate "untracked" parts
else:
build.unallocateUntracked(part=part)
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data)
class BuildComplete(AjaxUpdateView):
"""
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
ajax_form_title = _('Complete Build Order')
ajax_template_name = 'build/complete.html'
def validate(self, build, form, **kwargs):
if build.incomplete_count > 0:
form.add_error(None, _('Build order cannot be completed - incomplete outputs remain'))
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.
- Assignes (tracked) allocated items from stock to the build output
- Deletes pending BuildItem objects
"""
model = Build
form_class = forms.CompleteBuildOutputForm
context_object_name = "build"
ajax_form_title = _("Complete Build Output")
ajax_template_name = "build/complete_output.html"
def get_form(self):
build = self.get_object()
form = super().get_form()
# Extract the build output object
output = None
output_id = form['output'].value()
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
pass
if output:
if build.isFullyAllocated(output):
form.fields['confirm_incomplete'].widget = HiddenInput()
return form
def validate(self, build, form, **kwargs):
"""
Custom validation steps for the BuildOutputComplete" form
"""
data = form.cleaned_data
output = data.get('output', None)
stock_status = data.get('stock_status', StockStatus.OK)
# Any "invalid" stock status defaults to OK
try:
stock_status = int(stock_status)
except (ValueError):
stock_status = StockStatus.OK
if int(stock_status) not in StockStatus.keys():
form.add_error('stock_status', _('Invalid stock status value selected'))
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().get_initial()
build = self.get_object()
if build.part.default_location is not None:
try:
location = StockLocation.objects.get(pk=build.part.default_location.id)
initials['location'] = location
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
- Build information is required
"""
build = self.get_object()
context = {}
# Build object
context['build'] = build
form = self.get_form()
output = form['output'].value()
if output:
try:
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
return context
def save(self, build, form, **kwargs):
data = form.cleaned_data
location = data.get('location', None)
output = data.get('output', None)
stock_status = data.get('stock_status', StockStatus.OK)
# Any "invalid" stock status defaults to OK
try:
stock_status = int(stock_status)
except (ValueError):
stock_status = StockStatus.OK
# Complete the build output
build.completeBuildOutput(
output,
self.request.user,
location=location,
status=stock_status,
)
def get_data(self):
""" Provide feedback data back to the form """
return {
'success': _('Build output completed')
}
class BuildDetail(InvenTreeRoleMixin, DetailView):
""" Detail view of a single Build object. """
model = Build
template_name = 'build/detail.html'
context_object_name = 'build'
def get_context_data(self, **kwargs):
ctx = super(DetailView, self).get_context_data(**kwargs)
build = self.get_object()
ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False)
ctx['BuildStatus'] = BuildStatus
ctx['sub_build_count'] = build.sub_build_count()
part = build.part
bom_items = build.bom_items
ctx['part'] = part
ctx['bom_items'] = bom_items
ctx['has_tracked_bom_items'] = build.has_tracked_bom_items()
ctx['has_untracked_bom_items'] = build.has_untracked_bom_items()
return ctx
class BuildDelete(AjaxDeleteView):
""" View to delete a build """
model = Build
ajax_template_name = 'build/delete_build.html'
ajax_form_title = _('Delete Build Order')
class BuildItemDelete(AjaxDeleteView):
""" View to 'unallocate' a BuildItem.
Really we are deleting the BuildItem object from the database.
"""
model = BuildItem
ajax_template_name = 'build/delete_build_item.html'
ajax_form_title = _('Unallocate Stock')
context_object_name = 'item'
def get_data(self):
return {
'danger': _('Removed parts from build allocation')
}
class BuildItemCreate(AjaxCreateView):
"""
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 stock to build output')
# 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):
"""
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:
ctx['no_stock'] = True
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 = form['part_id'].value()
if part_id:
try:
self.part = Part.objects.get(pk=part_id)
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
# Reference to a StockItem object
item = None
# 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_id'] = part.pk
except Part.DoesNotExist:
pass
if build_id:
try:
build = Build.objects.get(pk=build_id)
initials['build'] = build
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)
elif required_quantity is not None:
quantity = required_quantity
item_id = self.get_param('item')
# If the request specifies a particular StockItem
if item_id:
try:
item = StockItem.objects.get(pk=item_id)
except (ValueError, StockItem.DoesNotExist):
pass
# If a StockItem is not selected, try to auto-select one
if item is None and part is not None:
items = StockItem.objects.filter(part=part)
if items.count() == 1:
item = items.first()
# Finally, if a StockItem is selected, ensure the quantity is not too much
if item is not None:
if quantity is None:
quantity = item.unallocated_quantity()
else:
quantity = min(quantity, item.unallocated_quantity())
if quantity is not None:
initials['quantity'] = quantity
return initials
class BuildItemEdit(AjaxUpdateView):
""" View to edit a BuildItem object """
model = BuildItem
ajax_template_name = 'build/edit_build_item.html'
form_class = forms.EditBuildItemForm
ajax_form_title = _('Edit Stock Allocation')
def get_data(self):
return {
'info': _('Updated Build Item'),
}
def get_form(self):
"""
Create form for editing a BuildItem.
- Limit the StockItem options to items that match the part
"""
form = super(BuildItemEdit, self).get_form()
# 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['install_into'].widget = HiddenInput()
return form