2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-30 12:36:45 +00:00

Merge remote-tracking branch 'inventree/master' into build-fixes

# Conflicts:
#	InvenTree/InvenTree/views.py
#	InvenTree/build/views.py
#	InvenTree/locale/de/LC_MESSAGES/django.po
#	InvenTree/locale/en/LC_MESSAGES/django.po
#	InvenTree/locale/es/LC_MESSAGES/django.po
#	InvenTree/order/views.py
#	InvenTree/part/api.py
#	InvenTree/part/views.py
#	InvenTree/templates/js/bom.js
This commit is contained in:
Oliver Walters 2020-10-30 22:44:25 +11:00
commit 3a702266e6
20 changed files with 1467 additions and 1132 deletions

View File

@ -213,6 +213,39 @@ class AjaxMixin(InvenTreeRoleMixin):
""" """
return {} 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.
If a form error is detected, add it to the form,
with 'form.add_error()'
Ref: https://docs.djangoproject.com/en/dev/topics/forms/
"""
# Do nothing by default
pass
def renderJsonResponse(self, request, form=None, data={}, context=None): def renderJsonResponse(self, request, form=None, data={}, context=None):
""" Render a JSON response based on specific class context. """ Render a JSON response based on specific class context.
@ -320,32 +353,6 @@ class AjaxCreateView(AjaxMixin, CreateView):
- Handles form validation via AJAX POST requests - Handles form validation via AJAX POST requests
""" """
def validate(self, request, form, cleaned_data, **kwargs):
"""
Hook for performing any extra validation, over and above the regular form.is_valid
If any errors exist, add them to the form, using form.add_error
"""
pass
def pre_save(self, form, request, **kwargs):
"""
Hook for doing something before the form is validated
"""
pass
def post_save(self, **kwargs):
"""
Hook for doing something with the created object after it is saved
kwargs:
request - The request object
new_object - The newly created object
"""
pass
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
""" Creates form with initial data, and renders JSON response """ """ Creates form with initial data, and renders JSON response """
@ -355,6 +362,17 @@ class AjaxCreateView(AjaxMixin, CreateView):
form = self.get_form() form = self.get_form()
return self.renderJsonResponse(request, form) return self.renderJsonResponse(request, form)
def do_save(self, form):
"""
Method for actually saving the form to the database.
Default implementation is very simple,
but can be overridden if required.
"""
self.object = form.save()
return self.object
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" Responds to form POST. Validates POST data and returns status info. """ Responds to form POST. Validates POST data and returns status info.
@ -365,13 +383,12 @@ class AjaxCreateView(AjaxMixin, CreateView):
self.request = request self.request = request
self.form = self.get_form() self.form = self.get_form()
# Perform regular form validation # Perform initial form validation
valid = self.form.is_valid() self.form.is_valid()
# Perform custom validation (no object can be provided yet)
self.validate(None, self.form)
# Perform any extra validation steps
self.validate(request, self.form, self.form.cleaned_data)
# Check if form is valid again (after performing any custom validation)
valid = self.form.is_valid() valid = self.form.is_valid()
# Extra JSON data sent alongside form # Extra JSON data sent alongside form
@ -379,11 +396,20 @@ class AjaxCreateView(AjaxMixin, CreateView):
'form_valid': valid 'form_valid': valid
} }
# Add in any extra class data
for value, key in enumerate(self.get_data()):
data[key] = value
if valid: if valid:
self.pre_save(self.form, request) # Perform (optional) pre-save step
self.object = self.form.save() self.pre_save(None, self.form)
self.post_save(new_object=self.object, request=request, form=self.form, data=self.form.cleaned_data)
# Save the object to the database
self.do_save(self.form)
# Perform (optional) post-save step
self.post_save(self.object, self.form)
# Return the PK of the newly-created object # Return the PK of the newly-created object
data['pk'] = self.object.pk data['pk'] = self.object.pk
@ -414,6 +440,17 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
return self.renderJsonResponse(request, self.get_form(), context=self.get_context_data()) return self.renderJsonResponse(request, self.get_form(), context=self.get_context_data())
def do_save(self, form):
"""
Method for updating the object in the database.
Default implementation is very simple,
but can be overridden if required.
"""
self.object = form.save()
return self.object
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" Respond to POST request. """ Respond to POST request.
@ -423,22 +460,44 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
- Otherwise, return sucess status - Otherwise, return sucess status
""" """
self.request = request
# Make sure we have an object to point to # Make sure we have an object to point to
self.object = self.get_object() self.object = self.get_object()
form = self.get_form() form = self.get_form()
# Perform initial form validation
form.is_valid()
# Perform custom validation
self.validate(self.object, form)
valid = form.is_valid()
data = { data = {
'form_valid': form.is_valid() 'form_valid': valid
} }
if form.is_valid(): # Add in any extra class data
obj = form.save() for value, key in enumerate(self.get_data()):
data[key] = value
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)
# Perform (optional) post-save step
self.post_save(obj, form)
# Include context data about the updated object # Include context data about the updated object
data['pk'] = obj.id data['pk'] = obj.pk
self.post_save(new_object=obj, request=request) self.post_save(obj, form)
try: try:
data['url'] = obj.get_absolute_url() data['url'] = obj.get_absolute_url()
@ -447,13 +506,6 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
return self.renderJsonResponse(request, form, data) return self.renderJsonResponse(request, form, data)
def post_save(self, **kwargs):
"""
Hook called after the form data is saved.
(Optional)
"""
pass
class AjaxDeleteView(AjaxMixin, UpdateView): class AjaxDeleteView(AjaxMixin, UpdateView):

View File

@ -60,30 +60,25 @@ class BuildCancel(AjaxUpdateView):
form_class = forms.CancelBuildForm form_class = forms.CancelBuildForm
role_required = 'build.change' role_required = 'build.change'
def post(self, request, *args, **kwargs): def validate(self, build, form, **kwargs):
""" Handle POST request. Mark the build status as CANCELLED """
build = self.get_object() confirm = str2bool(form.cleaned_data.get('confirm_cancel', False))
form = self.get_form() if not confirm:
valid = form.is_valid()
confirm = str2bool(request.POST.get('confirm_cancel', False))
if confirm:
build.cancelBuild(request.user)
else:
form.add_error('confirm_cancel', _('Confirm build cancellation')) form.add_error('confirm_cancel', _('Confirm build cancellation'))
valid = False
data = { def post_save(self, build, form, **kwargs):
'form_valid': valid, """
Cancel the build.
"""
build.cancelBuild(self.request.user)
def get_data(self):
return {
'danger': _('Build was cancelled') 'danger': _('Build was cancelled')
} }
return self.renderJsonResponse(request, form, data=data)
class BuildAutoAllocate(AjaxUpdateView): class BuildAutoAllocate(AjaxUpdateView):
""" View to auto-allocate parts for a build. """ View to auto-allocate parts for a build.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,7 @@ from .models import SalesOrderAllocation
class IssuePurchaseOrderForm(HelperForm): class IssuePurchaseOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Place order')) confirm = forms.BooleanField(required=True, initial=False, help_text=_('Place order'))
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -32,7 +32,7 @@ class IssuePurchaseOrderForm(HelperForm):
class CompletePurchaseOrderForm(HelperForm): class CompletePurchaseOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_("Mark order as complete")) confirm = forms.BooleanField(required=True, help_text=_("Mark order as complete"))
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -43,7 +43,7 @@ class CompletePurchaseOrderForm(HelperForm):
class CancelPurchaseOrderForm(HelperForm): class CancelPurchaseOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Cancel order')) confirm = forms.BooleanField(required=True, help_text=_('Cancel order'))
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -54,7 +54,7 @@ class CancelPurchaseOrderForm(HelperForm):
class CancelSalesOrderForm(HelperForm): class CancelSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Cancel order')) confirm = forms.BooleanField(required=True, help_text=_('Cancel order'))
class Meta: class Meta:
model = SalesOrder model = SalesOrder
@ -65,7 +65,7 @@ class CancelSalesOrderForm(HelperForm):
class ShipSalesOrderForm(HelperForm): class ShipSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Ship order')) confirm = forms.BooleanField(required=True, help_text=_('Ship order'))
class Meta: class Meta:
model = SalesOrder model = SalesOrder

View File

@ -209,6 +209,7 @@ class PurchaseOrder(Order):
line.save() line.save()
@transaction.atomic
def place_order(self): def place_order(self):
""" Marks the PurchaseOrder as PLACED. Order must be currently PENDING. """ """ Marks the PurchaseOrder as PLACED. Order must be currently PENDING. """
@ -217,6 +218,7 @@ class PurchaseOrder(Order):
self.issue_date = datetime.now().date() self.issue_date = datetime.now().date()
self.save() self.save()
@transaction.atomic
def complete_order(self): def complete_order(self):
""" Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """ """ Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """
@ -225,10 +227,16 @@ class PurchaseOrder(Order):
self.complete_date = datetime.now().date() self.complete_date = datetime.now().date()
self.save() self.save()
def can_cancel(self):
return self.status not in [
PurchaseOrderStatus.PLACED,
PurchaseOrderStatus.PENDING
]
def cancel_order(self): def cancel_order(self):
""" Marks the PurchaseOrder as CANCELLED. """ """ Marks the PurchaseOrder as CANCELLED. """
if self.status in [PurchaseOrderStatus.PLACED, PurchaseOrderStatus.PENDING]: if self.can_cancel():
self.status = PurchaseOrderStatus.CANCELLED self.status = PurchaseOrderStatus.CANCELLED
self.save() self.save()
@ -377,6 +385,16 @@ class SalesOrder(Order):
return True return True
def can_cancel(self):
"""
Return True if this order can be cancelled
"""
if not self.status == SalesOrderStatus.PENDING:
return False
return True
@transaction.atomic @transaction.atomic
def cancel_order(self): def cancel_order(self):
""" """
@ -386,7 +404,7 @@ class SalesOrder(Order):
- Delete any StockItems which have been allocated - Delete any StockItems which have been allocated
""" """
if not self.status == SalesOrderStatus.PENDING: if not self.can_cancel():
return False return False
self.status = SalesOrderStatus.CANCELLED self.status = SalesOrderStatus.CANCELLED

View File

@ -113,6 +113,7 @@ class POTests(OrderViewTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = json.loads(response.content) data = json.loads(response.content)
self.assertFalse(data['form_valid']) self.assertFalse(data['form_valid'])
# Test WITH confirmation # Test WITH confirmation
@ -151,7 +152,6 @@ class POTests(OrderViewTestCase):
response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
data = json.loads(response.content) data = json.loads(response.content)
self.assertFalse(data['form_valid']) self.assertFalse(data['form_valid'])
self.assertIn('Invalid Purchase Order', str(data['html_form']))
# POST with a part that does not match the purchase order # POST with a part that does not match the purchase order
post_data['order'] = 1 post_data['order'] = 1
@ -159,14 +159,12 @@ class POTests(OrderViewTestCase):
response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
data = json.loads(response.content) data = json.loads(response.content)
self.assertFalse(data['form_valid']) self.assertFalse(data['form_valid'])
self.assertIn('must match for Part and Order', str(data['html_form']))
# POST with an invalid part # POST with an invalid part
post_data['part'] = 12345 post_data['part'] = 12345
response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
data = json.loads(response.content) data = json.loads(response.content)
self.assertFalse(data['form_valid']) self.assertFalse(data['form_valid'])
self.assertIn('Invalid SupplierPart selection', str(data['html_form']))
# POST the form with valid data # POST the form with valid data
post_data['part'] = 100 post_data['part'] = 100

View File

@ -100,9 +100,9 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
ajax_template_name = "modal_form.html" ajax_template_name = "modal_form.html"
role_required = 'purchase_order.add' role_required = 'purchase_order.add'
def post_save(self, **kwargs): def post_save(self, attachment, form, **kwargs):
self.object.user = self.request.user attachment.user = self.request.user
self.object.save() attachment.save()
def get_data(self): def get_data(self):
return { return {
@ -148,7 +148,7 @@ class SalesOrderAttachmentCreate(AjaxCreateView):
ajax_form_title = _('Add Sales Order Attachment') ajax_form_title = _('Add Sales Order Attachment')
role_required = 'sales_order.add' role_required = 'sales_order.add'
def post_save(self, **kwargs): def post_save(self, attachment, form, **kwargs):
self.object.user = self.request.user self.object.user = self.request.user
self.object.save() self.object.save()
@ -319,11 +319,11 @@ class PurchaseOrderCreate(AjaxCreateView):
return initials return initials
def post_save(self, **kwargs): def post_save(self, order, form, **kwargs):
# Record the user who created this purchase order # Record the user who created this purchase order
self.object.created_by = self.request.user order.created_by = self.request.user
self.object.save() order.save()
class SalesOrderCreate(AjaxCreateView): class SalesOrderCreate(AjaxCreateView):
@ -351,10 +351,10 @@ class SalesOrderCreate(AjaxCreateView):
return initials return initials
def post_save(self, **kwargs): def post_save(self, order, form, **kwargs):
# Record the user who created this sales order # Record the user who created this sales order
self.object.created_by = self.request.user order.created_by = self.request.user
self.object.save() order.save()
class PurchaseOrderEdit(AjaxUpdateView): class PurchaseOrderEdit(AjaxUpdateView):
@ -404,29 +404,19 @@ class PurchaseOrderCancel(AjaxUpdateView):
form_class = order_forms.CancelPurchaseOrderForm form_class = order_forms.CancelPurchaseOrderForm
role_required = 'purchase_order.change' role_required = 'purchase_order.change'
def post(self, request, *args, **kwargs): def validate(self, order, form, **kwargs):
""" Mark the PO as 'CANCELLED' """
confirm = str2bool(form.cleaned_data.get('confirm', False))
order = self.get_object()
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
valid = False
if not confirm: if not confirm:
form.add_error('confirm', _('Confirm order cancellation')) form.add_error('confirm', _('Confirm order cancellation'))
else:
valid = True
data = { if not order.can_cancel():
'form_valid': valid form.add_error(None, _('Order cannot be cancelled'))
}
if valid: def post_save(self, order, form, **kwargs):
order.cancel_order()
return self.renderJsonResponse(request, form, data) order.cancel_order()
class SalesOrderCancel(AjaxUpdateView): class SalesOrderCancel(AjaxUpdateView):
@ -438,30 +428,19 @@ class SalesOrderCancel(AjaxUpdateView):
form_class = order_forms.CancelSalesOrderForm form_class = order_forms.CancelSalesOrderForm
role_required = 'sales_order.change' role_required = 'sales_order.change'
def post(self, request, *args, **kwargs): def validate(self, order, form, **kwargs):
order = self.get_object() confirm = str2bool(form.cleaned_data.get('confirm', False))
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
valid = False
if not confirm: if not confirm:
form.add_error('confirm', _('Confirm order cancellation')) form.add_error('confirm', _('Confirm order cancellation'))
else:
valid = True
if valid: if not order.can_cancel():
if not order.cancel_order(): form.add_error(None, _('Order cannot be cancelled'))
form.add_error(None, _('Could not cancel order'))
valid = False
data = { def post_save(self, order, form, **kwargs):
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data) order.cancel_order()
class PurchaseOrderIssue(AjaxUpdateView): class PurchaseOrderIssue(AjaxUpdateView):
@ -473,30 +452,22 @@ class PurchaseOrderIssue(AjaxUpdateView):
form_class = order_forms.IssuePurchaseOrderForm form_class = order_forms.IssuePurchaseOrderForm
role_required = 'purchase_order.change' role_required = 'purchase_order.change'
def post(self, request, *args, **kwargs): def validate(self, order, form, **kwargs):
""" Mark the purchase order as 'PLACED' """
order = self.get_object() confirm = str2bool(self.request.POST.get('confirm', False))
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
valid = False
if not confirm: if not confirm:
form.add_error('confirm', _('Confirm order placement')) form.add_error('confirm', _('Confirm order placement'))
else:
valid = True
data = { def post_save(self, order, form, **kwargs):
'form_valid': valid,
order.place_order()
def get_data(self):
return {
'success': _('Purchase order issued')
} }
if valid:
order.place_order()
return self.renderJsonResponse(request, form, data)
class PurchaseOrderComplete(AjaxUpdateView): class PurchaseOrderComplete(AjaxUpdateView):
""" View for marking a PurchaseOrder as complete. """ View for marking a PurchaseOrder as complete.
@ -517,23 +488,22 @@ class PurchaseOrderComplete(AjaxUpdateView):
return ctx return ctx
def post(self, request, *args, **kwargs): def validate(self, order, form, **kwargs):
confirm = str2bool(request.POST.get('confirm', False)) confirm = str2bool(form.cleaned_data.get('confirm', False))
if confirm: if not confirm:
po = self.get_object() form.add_error('confirm', _('Confirm order completion'))
po.status = PurchaseOrderStatus.COMPLETE
po.save()
data = { def post_save(self, order, form, **kwargs):
'form_valid': confirm
order.complete_order()
def get_data(self):
return {
'success': _('Purchase order completed')
} }
form = self.get_form()
return self.renderJsonResponse(request, form, data)
class SalesOrderShip(AjaxUpdateView): class SalesOrderShip(AjaxUpdateView):
""" View for 'shipping' a SalesOrder """ """ View for 'shipping' a SalesOrder """
@ -1117,52 +1087,21 @@ class POLineItemCreate(AjaxCreateView):
ajax_form_title = _('Add Line Item') ajax_form_title = _('Add Line Item')
role_required = 'purchase_order.add' role_required = 'purchase_order.add'
def post(self, request, *arg, **kwargs): def validate(self, item, form, **kwargs):
self.request = request order = form.cleaned_data.get('order', None)
form = self.get_form() part = form.cleaned_data.get('part', None)
valid = form.is_valid() if not part:
form.add_error('part', _('Supplier part must be specified'))
# Extract the SupplierPart ID from the form if part and order:
part_id = form['part'].value() if not part.supplier == order.supplier:
form.add_error(
# Extract the Order ID from the form 'part',
order_id = form['order'].value() _('Supplier must match for Part and Order')
)
try:
order = PurchaseOrder.objects.get(id=order_id)
except (ValueError, PurchaseOrder.DoesNotExist):
order = None
form.add_error('order', _('Invalid Purchase Order'))
valid = False
try:
sp = SupplierPart.objects.get(id=part_id)
if order is not None:
if not sp.supplier == order.supplier:
form.add_error('part', _('Supplier must match for Part and Order'))
valid = False
except (SupplierPart.DoesNotExist, ValueError):
valid = False
form.add_error('part', _('Invalid SupplierPart selection'))
data = {
'form_valid': valid,
}
if valid:
self.object = form.save()
data['pk'] = self.object.pk
data['text'] = str(self.object)
else:
self.object = None
return self.renderJsonResponse(request, form, data,)
def get_form(self): def get_form(self):
""" Limit choice options based on the selected order, etc """ Limit choice options based on the selected order, etc

View File

@ -786,12 +786,11 @@ class BomList(generics.ListCreateAPIView):
validated = params.get('validated', None) validated = params.get('validated', None)
if validated is not None: if validated is not None:
validated = str2bool(validated) validated = str2bool(validated)
# Work out which lines have actually been validated # Work out which lines have actually been validated
pks = [] pks = []
for bom_item in queryset.all(): for bom_item in queryset.all():
if bom_item.is_line_valid: if bom_item.is_line_valid:
pks.append(bom_item.pk) pks.append(bom_item.pk)

View File

@ -19,10 +19,15 @@ from .models import PartParameterTemplate, PartParameter
from .models import PartTestTemplate from .models import PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak
from common.models import Currency from common.models import Currency
class PartModelChoiceField(forms.ModelChoiceField):
""" Extending string representation of Part instance with available stock """
def label_from_instance(self, part):
return f'{part} - {part.available_stock}'
class PartImageForm(HelperForm): class PartImageForm(HelperForm):
""" Form for uploading a Part image """ """ Form for uploading a Part image """
@ -77,6 +82,38 @@ class BomExportForm(forms.Form):
self.fields['file_format'].choices = self.get_choices() self.fields['file_format'].choices = self.get_choices()
class BomDuplicateForm(HelperForm):
"""
Simple confirmation form for BOM duplication.
Select which parent to select from.
"""
parent = PartModelChoiceField(
label=_('Parent Part'),
help_text=_('Select parent part to copy BOM from'),
queryset=Part.objects.filter(is_template=True),
)
clear = forms.BooleanField(
required=False, initial=True,
help_text=_('Clear existing BOM items')
)
confirm = forms.BooleanField(
required=False, initial=False,
help_text=_('Confirm BOM duplication')
)
class Meta:
model = Part
fields = [
'parent',
'clear',
'confirm',
]
class BomValidateForm(HelperForm): class BomValidateForm(HelperForm):
""" Simple confirmation form for BOM validation. """ Simple confirmation form for BOM validation.
User is presented with a single checkbox input, User is presented with a single checkbox input,
@ -210,12 +247,6 @@ class EditCategoryForm(HelperForm):
] ]
class PartModelChoiceField(forms.ModelChoiceField):
""" Extending string representation of Part instance with available stock """
def label_from_instance(self, part):
return f'{part} - {part.available_stock}'
class EditBomItemForm(HelperForm): class EditBomItemForm(HelperForm):
""" Form for editing a BomItem object """ """ Form for editing a BomItem object """

View File

@ -1134,6 +1134,60 @@ class Part(MPTTModel):
max(buy_price_range[1], bom_price_range[1]) max(buy_price_range[1], bom_price_range[1])
) )
@transaction.atomic
def copy_bom_from(self, other, clear=True, **kwargs):
"""
Copy the BOM from another part.
args:
other - The part to copy the BOM from
clear - Remove existing BOM items first (default=True)
"""
if clear:
# Remove existing BOM items
self.bom_items.all().delete()
for bom_item in other.bom_items.all():
# If this part already has a BomItem pointing to the same sub-part,
# delete that BomItem from this part first!
try:
existing = BomItem.objects.get(part=self, sub_part=bom_item.sub_part)
existing.delete()
except (BomItem.DoesNotExist):
pass
bom_item.part = self
bom_item.pk = None
bom_item.save()
@transaction.atomic
def copy_parameters_from(self, other, **kwargs):
clear = kwargs.get('clear', True)
if clear:
self.get_parameters().delete()
for parameter in other.get_parameters():
# If this part already has a parameter pointing to the same template,
# delete that parameter from this part first!
try:
existing = PartParameter.objects.get(part=self, template=parameter.template)
existing.delete()
except (PartParameter.DoesNotExist):
pass
parameter.part = self
parameter.pk = None
parameter.save()
@transaction.atomic
def deepCopy(self, other, **kwargs): def deepCopy(self, other, **kwargs):
""" Duplicates non-field data from another part. """ Duplicates non-field data from another part.
Does not alter the normal fields of this part, Does not alter the normal fields of this part,
@ -1153,24 +1207,12 @@ class Part(MPTTModel):
# Copy the BOM data # Copy the BOM data
if kwargs.get('bom', False): if kwargs.get('bom', False):
for item in other.bom_items.all(): self.copy_bom_from(other)
# Point the item to THIS part.
# Set the pk to None so a new entry is created.
item.part = self
item.pk = None
item.save()
# Copy the parameters data # Copy the parameters data
if kwargs.get('parameters', True): if kwargs.get('parameters', True):
# Get template part parameters self.copy_parameters_from(other)
parameters = other.get_parameters()
# Copy template part parameters to new variant part
for parameter in parameters:
PartParameter.create(part=self,
template=parameter.template,
data=parameter.data,
save=True)
# Copy the fields that aren't available in the duplicate form # Copy the fields that aren't available in the duplicate form
self.salable = other.salable self.salable = other.salable
self.assembly = other.assembly self.assembly = other.assembly

View File

@ -39,8 +39,13 @@
<span class='fas fa-trash-alt'></span> <span class='fas fa-trash-alt'></span>
</button> </button>
<button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'> <button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'>
<span class='fas fa-file-upload'></span> {% trans "Upload" %} <span class='fas fa-file-upload'></span> {% trans "Import from File" %}
</button> </button>
{% if part.variant_of %}
<button class='btn btn-default' type='button' title='{% trans "Copy BOM from parent part" %}' id='bom-duplicate'>
<span class='fas fa-clone'></span> {% trans "Copy from Parent" %}
</button>
{% endif %}
<button class='btn btn-default' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'> <button class='btn btn-default' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
<span class='fas fa-plus-circle'></span> {% trans "Add Item" %} <span class='fas fa-plus-circle'></span> {% trans "Add Item" %}
</button> </button>
@ -157,6 +162,17 @@
location.href = "{% url 'upload-bom' part.id %}"; location.href = "{% url 'upload-bom' part.id %}";
}); });
$('#bom-duplicate').click(function() {
launchModalForm(
"{% url 'duplicate-bom' part.id %}",
{
success: function() {
$('#bom-table').bootstrapTable('refresh');
}
}
);
});
$("#bom-item-new").click(function () { $("#bom-item-new").click(function () {
launchModalForm( launchModalForm(
"{% url 'bom-item-create' %}?parent={{ part.id }}", "{% url 'bom-item-create' %}?parent={{ part.id }}",

View File

@ -0,0 +1,17 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<p>
{% trans "Select parent part to copy BOM from" %}
</p>
{% if part.has_bom %}
<div class='alert alert-block alert-danger'>
<b>{% trans "Warning" %}</b><br>
{% trans "This part already has a Bill of Materials" %}<br>
</div>
{% endif %}
{% endblock %}

View File

@ -46,6 +46,7 @@ part_detail_urls = [
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'),
url(r'^params/', views.PartDetail.as_view(template_name='part/params.html'), name='part-params'), url(r'^params/', views.PartDetail.as_view(template_name='part/params.html'), name='part-params'),
url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'), url(r'^variants/?', views.PartDetail.as_view(template_name='part/variants.html'), name='part-variants'),

View File

@ -371,6 +371,8 @@ class MakePartVariant(AjaxCreateView):
initials = model_to_dict(part_template) initials = model_to_dict(part_template)
initials['is_template'] = False initials['is_template'] = False
initials['variant_of'] = part_template initials['variant_of'] = part_template
initials['bom_copy'] = InvenTreeSetting.get_setting('PART_COPY_BOM')
initials['parameters_copy'] = InvenTreeSetting.get_setting('PART_COPY_PARAMETERS')
return initials return initials
@ -832,8 +834,60 @@ class PartEdit(AjaxUpdateView):
return form return form
class BomDuplicate(AjaxUpdateView):
"""
View for duplicating BOM from a parent item.
"""
model = Part
context_object_name = 'part'
ajax_form_title = _('Duplicate BOM')
ajax_template_name = 'part/bom_duplicate.html'
form_class = part_forms.BomDuplicateForm
role_required = 'part.change'
def get_form(self):
form = super().get_form()
# Limit choices to parents of the current part
parents = self.get_object().get_ancestors()
form.fields['parent'].queryset = parents
return form
def get_initial(self):
initials = super().get_initial()
parents = self.get_object().get_ancestors()
if parents.count() == 1:
initials['parent'] = parents[0]
return initials
def validate(self, part, form):
confirm = str2bool(form.cleaned_data.get('confirm', False))
if not confirm:
form.add_error('confirm', _('Confirm duplication of BOM from parent'))
def post_save(self, part, form):
parent = form.cleaned_data.get('parent', None)
clear = str2bool(form.cleaned_data.get('clear', True))
if parent:
part.copy_bom_from(parent, clear=clear)
class BomValidate(AjaxUpdateView): class BomValidate(AjaxUpdateView):
""" Modal form view for validating a part BOM """ """
Modal form view for validating a part BOM
"""
model = Part model = Part
ajax_form_title = _("Validate BOM") ajax_form_title = _("Validate BOM")
@ -854,23 +908,21 @@ class BomValidate(AjaxUpdateView):
return self.renderJsonResponse(request, form, context=self.get_context()) return self.renderJsonResponse(request, form, context=self.get_context())
def post(self, request, *args, **kwargs): def validate(self, part, form, **kwargs):
form = self.get_form() confirm = str2bool(form.cleaned_data.get('validate', False))
part = self.get_object()
confirmed = str2bool(request.POST.get('validate', False)) if not confirm:
if confirmed:
part.validate_bom(request.user)
else:
form.add_error('validate', _('Confirm that the BOM is valid')) form.add_error('validate', _('Confirm that the BOM is valid'))
data = { def post_save(self, part, form, **kwargs):
'form_valid': confirmed
}
return self.renderJsonResponse(request, form, data, context=self.get_context()) part.validate_bom(self.request.user)
def get_data(self):
return {
'success': _('Validated Bill of Materials')
}
class BomUpload(InvenTreeRoleMixin, FormView): class BomUpload(InvenTreeRoleMixin, FormView):

View File

@ -1143,6 +1143,22 @@ class StockItem(MPTTModel):
return s return s
@transaction.atomic
def clear_test_results(self, **kwargs):
"""
Remove all test results
kwargs:
TODO
"""
# All test results
results = self.test_results.all()
# TODO - Perhaps some filtering options supplied by kwargs?
results.delete()
def getTestResults(self, test=None, result=None, user=None): def getTestResults(self, test=None, result=None, user=None):
""" """
Return all test results associated with this StockItem. Return all test results associated with this StockItem.

View File

@ -164,11 +164,11 @@ class StockItemAttachmentCreate(AjaxCreateView):
ajax_template_name = "modal_form.html" ajax_template_name = "modal_form.html"
role_required = 'stock.add' role_required = 'stock.add'
def post_save(self, **kwargs): def post_save(self, attachment, form, **kwargs):
""" Record the user that uploaded the attachment """ """ Record the user that uploaded the attachment """
self.object.user = self.request.user attachment.user = self.request.user
self.object.save() attachment.save()
def get_data(self): def get_data(self):
return { return {
@ -245,32 +245,28 @@ class StockItemAssignToCustomer(AjaxUpdateView):
form_class = StockForms.AssignStockItemToCustomerForm form_class = StockForms.AssignStockItemToCustomerForm
role_required = 'stock.change' role_required = 'stock.change'
def post(self, request, *args, **kwargs): def validate(self, item, form, **kwargs):
customer = request.POST.get('customer', None) customer = form.cleaned_data.get('customer', None)
if not customer:
form.add_error('customer', _('Customer must be specified'))
def post_save(self, item, form, **kwargs):
"""
Assign the stock item to the customer.
"""
customer = form.cleaned_data.get('customer', None)
if customer: if customer:
try: item = item.allocateToCustomer(
customer = Company.objects.get(pk=customer)
except (ValueError, Company.DoesNotExist):
customer = None
if customer is not None:
stock_item = self.get_object()
item = stock_item.allocateToCustomer(
customer, customer,
user=request.user user=self.request.user
) )
item.clearAllocations() item.clearAllocations()
data = {
'form_valid': True,
}
return self.renderJsonResponse(request, self.get_form(), data)
class StockItemReturnToStock(AjaxUpdateView): class StockItemReturnToStock(AjaxUpdateView):
""" """
@ -283,30 +279,25 @@ class StockItemReturnToStock(AjaxUpdateView):
form_class = StockForms.ReturnStockItemForm form_class = StockForms.ReturnStockItemForm
role_required = 'stock.change' role_required = 'stock.change'
def post(self, request, *args, **kwargs): def validate(self, item, form, **kwargs):
location = request.POST.get('location', None) location = form.cleaned_data.get('location', None)
if not location:
form.add_error('location', _('Specify a valid location'))
def post_save(self, item, form, **kwargs):
location = form.cleaned_data.get('location', None)
if location: if location:
try: item.returnFromCustomer(location, self.request.user)
location = StockLocation.objects.get(pk=location)
except (ValueError, StockLocation.DoesNotExist):
location = None
if location: def get_data(self):
stock_item = self.get_object() return {
'success': _('Stock item returned from customer')
stock_item.returnFromCustomer(location, request.user)
else:
raise ValidationError({'location': _("Specify a valid location")})
data = {
'form_valid': True,
'success': _("Stock item returned from customer")
} }
return self.renderJsonResponse(request, self.get_form(), data)
class StockItemSelectLabels(AjaxView): class StockItemSelectLabels(AjaxView):
""" """
@ -440,11 +431,11 @@ class StockItemTestResultCreate(AjaxCreateView):
ajax_form_title = _("Add Test Result") ajax_form_title = _("Add Test Result")
role_required = 'stock.add' role_required = 'stock.add'
def post_save(self, **kwargs): def post_save(self, result, form, **kwargs):
""" Record the user that uploaded the test result """ """ Record the user that uploaded the test result """
self.object.user = self.request.user result.user = self.request.user
self.object.save() result.save()
def get_initial(self): def get_initial(self):

View File

@ -102,7 +102,7 @@ function loadBomTable(table, options) {
* *
* BOM data are retrieved from the server via AJAX query * BOM data are retrieved from the server via AJAX query
*/ */
var params = { var params = {
part: options.parent_id, part: options.parent_id,
ordering: 'name', ordering: 'name',
@ -111,22 +111,22 @@ function loadBomTable(table, options) {
if (options.part_detail) { if (options.part_detail) {
params.part_detail = true; params.part_detail = true;
} }
if (options.sub_part_detail) { params.sub_part_detail = true;
params.sub_part_detail = true;
}
var filters = {}; var filters = {};
if (!options.disableFilters) { if (!options.disableFilters) {
filters = loadTableFilters("bom"); filters = loadTableFilters('bom');
} }
for (var key in params) { for (var key in params) {
filters[key] = params[key]; filters[key] = params[key];
} }
setupFilterList("bom", $(table)); setupFilterList('bom', $(table));
// Construct the table columns
var cols = []; var cols = [];
@ -161,7 +161,7 @@ function loadBomTable(table, options) {
} }
if (sub_part.is_template) { if (sub_part.is_template) {
html += makeIconBadge('fa-clone', '{% trans "Templat part" %}'); html += makeIconBadge('fa-clone', '{% trans "Template part" %}');
} }
// Display an extra icon if this part is an assembly // Display an extra icon if this part is an assembly
@ -357,7 +357,7 @@ function loadBomTable(table, options) {
return {classes: 'rowinvalid'}; return {classes: 'rowinvalid'};
} }
}, },
formatNoMatches: function() { return "{% trans "No BOM items found" %}"; }, formatNoMatches: function() { return '{% trans "No BOM items found" %}'; },
clickToSelect: true, clickToSelect: true,
queryParams: filters, queryParams: filters,
original: params, original: params,