From aa30e62ad80b120d3b36bf2024cd844a060263a9 Mon Sep 17 00:00:00 2001
From: Matthias
Date: Sun, 1 May 2022 00:07:13 +0200
Subject: [PATCH 16/47] Use set_null instead
---
InvenTree/company/admin.py | 9 ---------
InvenTree/company/models.py | 28 ----------------------------
InvenTree/order/models.py | 6 +++---
3 files changed, 3 insertions(+), 40 deletions(-)
diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py
index af767f3fdc..97327a559a 100644
--- a/InvenTree/company/admin.py
+++ b/InvenTree/company/admin.py
@@ -21,7 +21,6 @@ class CompanyResource(ModelResource):
class Meta:
model = Company
- exclude = ('is_deleted', )
skip_unchanged = True
report_skipped = False
clean_model_instances = True
@@ -38,14 +37,6 @@ class CompanyAdmin(ImportExportModelAdmin):
'description',
]
- exclude = ('is_deleted',)
-
- def has_delete_permission(self, request, obj=None):
- """Magic objects are not allowd to be deleted"""
- if obj and obj.is_deleted:
- return False
- return True
-
class SupplierPartResource(ModelResource):
"""
diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py
index 7866fbc8a7..e4b3a1b640 100644
--- a/InvenTree/company/models.py
+++ b/InvenTree/company/models.py
@@ -60,20 +60,6 @@ def rename_company_image(instance, filename):
return os.path.join(base, fn)
-def get_deleted_company():
- """
- Returns the deleted company object
- """
- return Company.objects.get_or_create(
- name='deleted',
- email='deleted',
- is_deleted=True,
- is_customer=True,
- is_supplier=True,
- is_manufacturer=True
- )[0]
-
-
class Company(models.Model):
""" A Company object represents an external company.
It may be a supplier or a customer or a manufacturer (or a combination)
@@ -161,8 +147,6 @@ class Company(models.Model):
is_manufacturer = models.BooleanField(default=False, verbose_name=_('is manufacturer'), help_text=_('Does this company manufacture parts?'))
- is_deleted = models.BooleanField(default=False, verbose_name=_('is deleted'), help_text=_('Is this company a deleted placeholder?'))
-
currency = models.CharField(
max_length=3,
verbose_name=_('Currency'),
@@ -282,18 +266,6 @@ class Company(models.Model):
return self.purchase_orders.filter(status__in=PurchaseOrderStatus.FAILED)
- def save(self, *args, **kwargs):
- """Save the instance, unless it is the magic already deleted object"""
- if self.pk and self.is_deleted:
- raise PermissionDenied(_('This company is a placeholder and can not be updated'))
- return super().save(*args, **kwargs)
-
- def delete(self, *args, **kwargs):
- """Delete the instance, unless it is the magic already deleted object"""
- if self.is_deleted:
- raise PermissionDenied(_('This company is a placeholder and can not be deleted'))
- return super().delete(*args, **kwargs)
-
class Contact(models.Model):
""" A Contact represents a person who works at a particular company.
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
index b9bec62890..2eb5b0d69c 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -24,7 +24,7 @@ from mptt.models import TreeForeignKey
from users import models as UserModels
from part import models as PartModels
from stock import models as stock_models
-from company.models import Company, SupplierPart, get_deleted_company
+from company.models import Company, SupplierPart
from plugin.events import trigger_event
import InvenTree.helpers
@@ -219,7 +219,7 @@ class PurchaseOrder(Order):
help_text=_('Purchase order status'))
supplier = models.ForeignKey(
- Company, on_delete=models.SET(get_deleted_company),
+ Company, on_delete=models.SET_NULL,
limit_choices_to={
'is_supplier': True,
},
@@ -567,7 +567,7 @@ class SalesOrder(Order):
customer = models.ForeignKey(
Company,
- on_delete=models.SET(get_deleted_company),
+ on_delete=models.SET_NULL,
null=True,
limit_choices_to={'is_customer': True},
related_name='sales_orders',
From 9947cc2b08721e2727bf7a95ce1b65ee0b7983e1 Mon Sep 17 00:00:00 2001
From: Matthias
Date: Sun, 1 May 2022 00:07:37 +0200
Subject: [PATCH 17/47] remove old permissionset
---
InvenTree/company/templates/company/company_base.html | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html
index 4adf4adb3b..c58ea63791 100644
--- a/InvenTree/company/templates/company/company_base.html
+++ b/InvenTree/company/templates/company/company_base.html
@@ -18,23 +18,23 @@
{% url 'admin:company_company_change' company.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
-{% if company.is_supplier and roles.purchase_order.add and not company.is_deleted %}
+{% if company.is_supplier and roles.purchase_order.add %}
{% endif %}
{% define perms.company.change_company or perms.company.delete_company as has_permission %}
-{% if not company.is_deleted and has_permission %}
+{% if has_permission %}
{% blocktrans with count=part.supplier_parts.all|length %}There are {{count}} suppliers defined for this part. If you delete this part, the following supplier parts will also be deleted:{% endblocktrans %}
Date: Mon, 2 May 2022 22:46:03 +0200
Subject: [PATCH 23/47] fix __str__ instance
---
InvenTree/order/models.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
index 65aa368798..060d638de1 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -228,7 +228,7 @@ class PurchaseOrder(Order):
prefix = getSetting('PURCHASEORDER_REFERENCE_PREFIX')
- return f"{prefix}{self.reference} - {self.supplier.name}"
+ return f"{prefix}{self.reference} - {self.supplier.name if self.supplier else _('deleted')}"
reference = models.CharField(
unique=True,
@@ -576,7 +576,7 @@ class SalesOrder(Order):
prefix = getSetting('SALESORDER_REFERENCE_PREFIX')
- return f"{prefix}{self.reference} - {self.customer.name}"
+ return f"{prefix}{self.reference} - {self.customer.name if self.customer else _('deleted')}"
def get_absolute_url(self):
return reverse('so-detail', kwargs={'pk': self.id})
@@ -939,7 +939,7 @@ class PurchaseOrderLineItem(OrderLineItem):
return "{n} x {part} from {supplier} (for {po})".format(
n=decimal2string(self.quantity),
part=self.part.SKU if self.part else 'unknown part',
- supplier=self.order.supplier.name,
+ supplier=self.order.supplier.name if self.order.supplier else _('deleted'),
po=self.order)
order = models.ForeignKey(
From c7980a347eab283978ce6017212d948456887144 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 15:06:02 +1000
Subject: [PATCH 24/47] Adds ability to add custom serializer context
information to the OPTIONS endpoint
- Will be useful for constructing prettier forms with information about the endpoint
---
InvenTree/InvenTree/metadata.py | 21 +++++++++++++++
InvenTree/order/api.py | 47 ++++++++++++++++++++++-----------
InvenTree/order/models.py | 1 +
InvenTree/order/serializers.py | 29 ++++++++++++++++++++
4 files changed, 83 insertions(+), 15 deletions(-)
diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py
index e0f8a23322..792714e439 100644
--- a/InvenTree/InvenTree/metadata.py
+++ b/InvenTree/InvenTree/metadata.py
@@ -37,6 +37,25 @@ class InvenTreeMetadata(SimpleMetadata):
metadata = super().determine_metadata(request, view)
+ """
+ Custom context information to pass through to the OPTIONS endpoint,
+ if the "context=True" is supplied to the OPTIONS requst
+
+ Serializer class can supply either:
+
+ - get_context_data() (method)
+ - CONTEXT_DATA (dict)
+ """
+
+ context = {}
+
+ if hasattr(self.serializer, 'get_context_data'):
+ context = self.serializer.get_context_data()
+ elif hasattr(self.erializer, 'CONTEXT_DATA'):
+ context = self.serializer.CONTEXT_DATA
+
+ metadata['context'] = context
+
user = request.user
if user is None:
@@ -99,6 +118,8 @@ class InvenTreeMetadata(SimpleMetadata):
to any fields whose Meta.model specifies a default value
"""
+ self.serializer = serializer
+
serializer_info = super().get_serializer_info(serializer)
model_class = None
diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py
index a54c370121..12ceb600fc 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -286,7 +286,37 @@ class PurchaseOrderDetail(generics.RetrieveUpdateDestroyAPIView):
return queryset
-class PurchaseOrderReceive(generics.CreateAPIView):
+class PurchaseOrderContextMixin:
+
+ def get_serializer_context(self):
+ """ Add the PurchaseOrder object to the serializer context """
+
+ context = super().get_serializer_context()
+
+ # Pass the purchase order through to the serializer for validation
+ try:
+ context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
+ except:
+ pass
+
+ context['request'] = self.request
+
+ return context
+
+
+class PurchaseOrderCancel(PurchaseOrderContextMixin, generics.CreateAPIView):
+ """
+ API endpoint to 'cancel' a purchase order.
+
+ The purchase order must be in a state which can be cancelled
+ """
+
+ queryset = models.PurchaseOrderLineItem.objects.all()
+
+ serializer_class = serializers.PurchaseOrderCancelSerializer
+
+
+class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
"""
API endpoint to receive stock items against a purchase order.
@@ -303,20 +333,6 @@ class PurchaseOrderReceive(generics.CreateAPIView):
serializer_class = serializers.PurchaseOrderReceiveSerializer
- def get_serializer_context(self):
-
- context = super().get_serializer_context()
-
- # Pass the purchase order through to the serializer for validation
- try:
- context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
- except:
- pass
-
- context['request'] = self.request
-
- return context
-
class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
"""
@@ -1107,6 +1123,7 @@ order_api_urls = [
# Individual purchase order detail URLs
re_path(r'^(?P\d+)/', include([
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
+ re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
])),
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
index 2b722ddecd..af1b159afa 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -380,6 +380,7 @@ class PurchaseOrder(Order):
PurchaseOrderStatus.PENDING
]
+ @transaction.atomic
def cancel_order(self):
""" Marks the PurchaseOrder as CANCELLED. """
diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py
index 7d26ce741d..a151da77b9 100644
--- a/InvenTree/order/serializers.py
+++ b/InvenTree/order/serializers.py
@@ -179,6 +179,35 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializ
]
+class PurchaseOrderCancelSerializer(serializers.Serializer):
+ """
+ Serializer for cancelling a PurchaseOrder
+ """
+
+ class Meta:
+ fields = [],
+
+ def get_context_data(self):
+ """
+ Return custom context information about the order
+ """
+
+ self.order = self.context['order']
+
+ return {
+ 'can_cancel': self.order.can_cancel(),
+ }
+
+ def save(self):
+
+ order = self.context['order']
+
+ if not order.can_cancel():
+ raise ValidationError(_("Order cannot be cancelled"))
+
+ order.cancel_order()
+
+
class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
@staticmethod
From e920fc31e786dd53e0d0c5bfba4abc13d8e435c5 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 15:07:22 +1000
Subject: [PATCH 25/47] Only include extra context information if requested
---
InvenTree/InvenTree/metadata.py | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py
index 792714e439..bb913e2179 100644
--- a/InvenTree/InvenTree/metadata.py
+++ b/InvenTree/InvenTree/metadata.py
@@ -9,6 +9,8 @@ from rest_framework.metadata import SimpleMetadata
from rest_framework.utils import model_meta
from rest_framework.fields import empty
+from InvenTree.helpers import str2bool
+
import users.models
@@ -49,12 +51,14 @@ class InvenTreeMetadata(SimpleMetadata):
context = {}
- if hasattr(self.serializer, 'get_context_data'):
- context = self.serializer.get_context_data()
- elif hasattr(self.erializer, 'CONTEXT_DATA'):
- context = self.serializer.CONTEXT_DATA
+ if str2bool(request.query_params.get('context', False)):
- metadata['context'] = context
+ if hasattr(self.serializer, 'get_context_data'):
+ context = self.serializer.get_context_data()
+ elif hasattr(self.erializer, 'CONTEXT_DATA'):
+ context = self.serializer.CONTEXT_DATA
+
+ metadata['context'] = context
user = request.user
From 62cd0f39c7b45405cd77f807528559664f42ca10 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 15:14:14 +1000
Subject: [PATCH 26/47] Replace the existing CancelPurchaseOrderForm with an
API driven form
---
InvenTree/order/forms.py | 11 --------
.../order/templates/order/order_base.html | 12 ++++++---
.../order/templates/order/order_cancel.html | 11 --------
InvenTree/order/urls.py | 1 -
InvenTree/order/views.py | 26 -------------------
InvenTree/templates/js/translated/forms.js | 3 +++
InvenTree/templates/js/translated/order.js | 25 ++++++++++++++++++
7 files changed, 37 insertions(+), 52 deletions(-)
delete mode 100644 InvenTree/order/templates/order/order_cancel.html
diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py
index d6b99b7fd9..0ac55a2f22 100644
--- a/InvenTree/order/forms.py
+++ b/InvenTree/order/forms.py
@@ -41,17 +41,6 @@ class CompletePurchaseOrderForm(HelperForm):
]
-class CancelPurchaseOrderForm(HelperForm):
-
- confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Cancel order'))
-
- class Meta:
- model = PurchaseOrder
- fields = [
- 'confirm',
- ]
-
-
class CancelSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Cancel order'))
diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html
index c2aa10f722..70d791081b 100644
--- a/InvenTree/order/templates/order/order_base.html
+++ b/InvenTree/order/templates/order/order_base.html
@@ -258,9 +258,15 @@ $("#complete-order").click(function() {
});
$("#cancel-order").click(function() {
- launchModalForm("{% url 'po-cancel' order.id %}", {
- reload: true,
- });
+
+ cancelPurchaseOrder(
+ {{ order.pk }},
+ {
+ onSuccess: function() {
+ window.location.reload();
+ }
+ },
+ );
});
$("#export-order").click(function() {
diff --git a/InvenTree/order/templates/order/order_cancel.html b/InvenTree/order/templates/order/order_cancel.html
deleted file mode 100644
index 7cdb03ae20..0000000000
--- a/InvenTree/order/templates/order/order_cancel.html
+++ /dev/null
@@ -1,11 +0,0 @@
-{% extends "modal_form.html" %}
-
-{% load i18n %}
-
-{% block pre_form_content %}
-
-
- {% trans "Cancelling this order means that the order and line items will no longer be editable." %}
-
-
-{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py
index f82a581828..79bd897a4d 100644
--- a/InvenTree/order/urls.py
+++ b/InvenTree/order/urls.py
@@ -11,7 +11,6 @@ from . import views
purchase_order_detail_urls = [
- re_path(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
re_path(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'),
re_path(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'),
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index 68b45ebe86..a3f4fc68e6 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -87,32 +87,6 @@ class SalesOrderDetail(InvenTreeRoleMixin, DetailView):
template_name = 'order/sales_order_detail.html'
-class PurchaseOrderCancel(AjaxUpdateView):
- """ View for cancelling a purchase order """
-
- model = PurchaseOrder
- ajax_form_title = _('Cancel Order')
- ajax_template_name = 'order/order_cancel.html'
- form_class = order_forms.CancelPurchaseOrderForm
-
- def validate(self, order, form, **kwargs):
-
- confirm = str2bool(form.cleaned_data.get('confirm', False))
-
- if not confirm:
- form.add_error('confirm', _('Confirm order cancellation'))
-
- if not order.can_cancel():
- form.add_error(None, _('Order cannot be cancelled'))
-
- def save(self, order, form, **kwargs):
- """
- Cancel the PurchaseOrder
- """
-
- order.cancel_order()
-
-
class SalesOrderCancel(AjaxUpdateView):
""" View for cancelling a sales order """
diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index cc138052ef..fe28bb0653 100644
--- a/InvenTree/templates/js/translated/forms.js
+++ b/InvenTree/templates/js/translated/forms.js
@@ -123,6 +123,9 @@ function getApiEndpointOptions(url, callback) {
return;
}
+ // Include extra context information in the request
+ url += '?context=true'
+
// Return the ajax request object
$.ajax({
url: url,
diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js
index d5ca7caf42..c1abe2cafb 100644
--- a/InvenTree/templates/js/translated/order.js
+++ b/InvenTree/templates/js/translated/order.js
@@ -20,6 +20,7 @@
/* exported
allocateStockToSalesOrder,
+ cancelPurchaseOrder,
completeShipment,
createSalesOrder,
createSalesOrderShipment,
@@ -141,6 +142,30 @@ function completeShipment(shipment_id) {
}
+function cancelPurchaseOrder(order_id, options={}) {
+
+ var html = `
+
+ {% trans "Are you sure you wish to cancel this purchase order?" %}
+ `;
+
+ constructForm(
+ `/api/order/po/${order_id}/cancel/`,
+ {
+ method: 'POST',
+ title: '{% trans "Cancel Purchase Order" %}',
+ confirm: true,
+ preFormContent: html,
+ onSuccess: function(response) {
+ if (options.onSuccess) {
+ options.onSuccess(response);
+ }
+ }
+ }
+ );
+}
+
+
// Open a dialog to create a new sales order shipment
function createSalesOrderShipment(options={}) {
From 4e7d1d8e7f6ac8ccccd725d2511aab0e65e720fc Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 15:16:50 +1000
Subject: [PATCH 27/47] Bump API version
---
InvenTree/InvenTree/api_version.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index 1ef1df6a8f..e7b9d4d95a 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -4,11 +4,15 @@ InvenTree API version information
# InvenTree API version
-INVENTREE_API_VERSION = 43
+INVENTREE_API_VERSION = 44
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
+v44 -> 2022-05-04 : https://github.com/inventree/InvenTree/pull/2931
+ - Converting more server-side rendered forms to the API
+ - Exposes more core functionality to API endpoints
+
v43 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2875
- Adds API detail endpoint for PartSalePrice model
- Adds API detail endpoint for PartInternalPrice model
From e527ddcc86423bb47ebebaea11fd045f5dd50056 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 15:31:33 +1000
Subject: [PATCH 28/47] Extract context information from the OPTIONS request
(forms.js)
---
InvenTree/InvenTree/metadata.py | 7 +------
InvenTree/templates/js/translated/forms.js | 12 +++++++++++-
2 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py
index bb913e2179..8a8be64a37 100644
--- a/InvenTree/InvenTree/metadata.py
+++ b/InvenTree/InvenTree/metadata.py
@@ -43,10 +43,7 @@ class InvenTreeMetadata(SimpleMetadata):
Custom context information to pass through to the OPTIONS endpoint,
if the "context=True" is supplied to the OPTIONS requst
- Serializer class can supply either:
-
- - get_context_data() (method)
- - CONTEXT_DATA (dict)
+ Serializer class can supply context data by defining a get_context_data() method (no arguments)
"""
context = {}
@@ -55,8 +52,6 @@ class InvenTreeMetadata(SimpleMetadata):
if hasattr(self.serializer, 'get_context_data'):
context = self.serializer.get_context_data()
- elif hasattr(self.erializer, 'CONTEXT_DATA'):
- context = self.serializer.CONTEXT_DATA
metadata['context'] = context
diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index fe28bb0653..df5c864b50 100644
--- a/InvenTree/templates/js/translated/forms.js
+++ b/InvenTree/templates/js/translated/forms.js
@@ -338,6 +338,9 @@ function constructForm(url, options) {
// Request OPTIONS endpoint from the API
getApiEndpointOptions(url, function(OPTIONS) {
+ // Extract any custom 'context' information from the OPTIONS data
+ options.context = OPTIONS.context || {};
+
/*
* Determine what "type" of form we want to construct,
* based on the requested action.
@@ -530,7 +533,14 @@ function constructFormBody(fields, options) {
$(modal).find('#form-content').html(html);
if (options.preFormContent) {
- $(modal).find('#pre-form-content').html(options.preFormContent);
+
+ if (typeof(options.preFormContent) === 'function') {
+ var content = options.preFormContent(options);
+ } else {
+ var content = options.preFormContent;
+ }
+
+ $(modal).find('#pre-form-content').html(content);
}
if (options.postFormContent) {
From 5afc3bfce2143a2fed5ccc949ba40034355a0038 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 15:33:50 +1000
Subject: [PATCH 29/47] Replace PurchaseOrderComplete form
---
InvenTree/order/api.py | 13 +++++-
InvenTree/order/forms.py | 11 -----
InvenTree/order/serializers.py | 26 ++++++++++++
.../order/templates/order/order_base.html | 12 ++++--
.../order/templates/order/order_complete.html | 15 -------
InvenTree/order/urls.py | 2 -
InvenTree/order/views.py | 38 -----------------
InvenTree/templates/js/translated/order.js | 41 +++++++++++++++++++
8 files changed, 88 insertions(+), 70 deletions(-)
delete mode 100644 InvenTree/order/templates/order/order_complete.html
diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py
index 12ceb600fc..9a76f1b451 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -311,11 +311,21 @@ class PurchaseOrderCancel(PurchaseOrderContextMixin, generics.CreateAPIView):
The purchase order must be in a state which can be cancelled
"""
- queryset = models.PurchaseOrderLineItem.objects.all()
+ queryset = models.PurchaseOrder.objects.all()
serializer_class = serializers.PurchaseOrderCancelSerializer
+class PurchaseOrderComplete(PurchaseOrderContextMixin, generics.CreateAPIView):
+ """
+ API endpoint to 'complete' a purchase order
+ """
+
+ queryset = models.PurchaseOrder.objects.all()
+
+ serializer_class = serializers.PurchaseOrderCompleteSerializer
+
+
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
"""
API endpoint to receive stock items against a purchase order.
@@ -1124,6 +1134,7 @@ order_api_urls = [
re_path(r'^(?P\d+)/', include([
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
+ re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
])),
diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py
index 0ac55a2f22..f9ece96bda 100644
--- a/InvenTree/order/forms.py
+++ b/InvenTree/order/forms.py
@@ -30,17 +30,6 @@ class IssuePurchaseOrderForm(HelperForm):
]
-class CompletePurchaseOrderForm(HelperForm):
-
- confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_("Mark order as complete"))
-
- class Meta:
- model = PurchaseOrder
- fields = [
- 'confirm',
- ]
-
-
class CancelSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Cancel order'))
diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py
index a151da77b9..47ec14377d 100644
--- a/InvenTree/order/serializers.py
+++ b/InvenTree/order/serializers.py
@@ -208,6 +208,32 @@ class PurchaseOrderCancelSerializer(serializers.Serializer):
order.cancel_order()
+class PurchaseOrderCompleteSerializer(serializers.Serializer):
+ """
+ Serializer for completing a purchase order
+ """
+
+ class Meta:
+ fields = []
+
+ def get_context_data(self):
+ """
+ Custom context information for this serializer
+ """
+
+ order = self.context['order']
+
+ return {
+ 'is_complete': order.is_complete,
+ }
+
+ def save(self):
+
+ order = self.context['order']
+
+
+
+
class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
@staticmethod
diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html
index 70d791081b..dc0c4344b1 100644
--- a/InvenTree/order/templates/order/order_base.html
+++ b/InvenTree/order/templates/order/order_base.html
@@ -252,9 +252,15 @@ $("#receive-order").click(function() {
});
$("#complete-order").click(function() {
- launchModalForm("{% url 'po-complete' order.id %}", {
- reload: true,
- });
+
+ completePurchaseOrder(
+ {{ order.pk }},
+ {
+ onSuccess: function() {
+ window.location.reload();
+ }
+ }
+ );
});
$("#cancel-order").click(function() {
diff --git a/InvenTree/order/templates/order/order_complete.html b/InvenTree/order/templates/order/order_complete.html
deleted file mode 100644
index ef35841f9d..0000000000
--- a/InvenTree/order/templates/order/order_complete.html
+++ /dev/null
@@ -1,15 +0,0 @@
-{% extends "modal_form.html" %}
-
-{% load i18n %}
-
-{% block pre_form_content %}
-
-{% trans 'Mark this order as complete?' %}
-{% if not order.is_complete %}
-
- {% trans 'This order has line items which have not been marked as received.' %}
- {% trans 'Completing this order means that the order and line items will no longer be editable.' %}
-
-{% endif %}
-
-{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py
index 79bd897a4d..74c7976379 100644
--- a/InvenTree/order/urls.py
+++ b/InvenTree/order/urls.py
@@ -12,8 +12,6 @@ from . import views
purchase_order_detail_urls = [
re_path(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'),
- re_path(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'),
-
re_path(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'),
re_path(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'),
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index a3f4fc68e6..b36f64c937 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -140,44 +140,6 @@ class PurchaseOrderIssue(AjaxUpdateView):
}
-class PurchaseOrderComplete(AjaxUpdateView):
- """ View for marking a PurchaseOrder as complete.
- """
-
- form_class = order_forms.CompletePurchaseOrderForm
- model = PurchaseOrder
- ajax_template_name = "order/order_complete.html"
- ajax_form_title = _("Complete Order")
- context_object_name = 'order'
-
- def get_context_data(self):
-
- ctx = {
- 'order': self.get_object(),
- }
-
- return ctx
-
- def validate(self, order, form, **kwargs):
-
- confirm = str2bool(form.cleaned_data.get('confirm', False))
-
- if not confirm:
- form.add_error('confirm', _('Confirm order completion'))
-
- def save(self, order, form, **kwargs):
- """
- Complete the PurchaseOrder
- """
-
- order.complete_order()
-
- def get_data(self):
- return {
- 'success': _('Purchase order completed')
- }
-
-
class PurchaseOrderUpload(FileManagementFormView):
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js
index c1abe2cafb..aedbb5a5bf 100644
--- a/InvenTree/templates/js/translated/order.js
+++ b/InvenTree/templates/js/translated/order.js
@@ -21,6 +21,7 @@
/* exported
allocateStockToSalesOrder,
cancelPurchaseOrder,
+ completePurchaseOrder,
completeShipment,
createSalesOrder,
createSalesOrderShipment,
@@ -142,6 +143,46 @@ function completeShipment(shipment_id) {
}
+function completePurchaseOrder(order_id, options={}) {
+
+ constructForm(
+ `/api/order/po/${order_id}/complete/`,
+ {
+ method: 'POST',
+ title: '{% trans "Complete Purchase Order" %}',
+ confirm: true,
+ preFormContent: function(opts) {
+
+ var html = `
+
+ {% trans "Mark this order as complete?" %}
+ `;
+
+ if (opts.context.is_complete) {
+ html += `
+
+ {% trans "All line items have been received" %}
+ `;
+ } else {
+ html += `
+
+ {% trans 'This order has line items which have not been marked as received.' %}
+ {% trans 'Completing this order means that the order and line items will no longer be editable.' %}
+ `;
+ }
+
+ return html;
+ },
+ onSuccess: function(response) {
+ if (options.onSuccess) {
+ options.onSuccess(response);
+ }
+ }
+ }
+ );
+}
+
+
function cancelPurchaseOrder(order_id, options={}) {
var html = `
From bf48e3204b0a4a999806c4aa9a8ea8b024bf9247 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 15:45:13 +1000
Subject: [PATCH 30/47] Refactor PurchaseOrderIssue form
---
InvenTree/order/api.py | 12 ++++
InvenTree/order/forms.py | 10 ---
InvenTree/order/serializers.py | 12 ++++
.../order/templates/order/order_base.html | 12 ++--
.../order/templates/order/order_issue.html | 11 ----
InvenTree/order/urls.py | 1 -
InvenTree/order/views.py | 27 --------
InvenTree/templates/js/translated/order.js | 65 +++++++++++++++----
8 files changed, 84 insertions(+), 66 deletions(-)
delete mode 100644 InvenTree/order/templates/order/order_issue.html
diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py
index 9a76f1b451..e93c5b093f 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -326,6 +326,17 @@ class PurchaseOrderComplete(PurchaseOrderContextMixin, generics.CreateAPIView):
serializer_class = serializers.PurchaseOrderCompleteSerializer
+class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
+ """
+ API endpoint to 'complete' a purchase order
+ """
+
+ queryset = models.PurchaseOrder.objects.all()
+
+ serializer_class = serializers.PurchaseOrderIssueSerializer
+
+
+
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
"""
API endpoint to receive stock items against a purchase order.
@@ -1132,6 +1143,7 @@ order_api_urls = [
# Individual purchase order detail URLs
re_path(r'^(?P\d+)/', include([
+ re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py
index f9ece96bda..75b6cb94ec 100644
--- a/InvenTree/order/forms.py
+++ b/InvenTree/order/forms.py
@@ -19,16 +19,6 @@ from .models import PurchaseOrder
from .models import SalesOrder
-class IssuePurchaseOrderForm(HelperForm):
-
- confirm = forms.BooleanField(required=True, initial=False, label=_('Confirm'), help_text=_('Place order'))
-
- class Meta:
- model = PurchaseOrder
- fields = [
- 'confirm',
- ]
-
class CancelSalesOrderForm(HelperForm):
diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py
index 47ec14377d..5bd753f17f 100644
--- a/InvenTree/order/serializers.py
+++ b/InvenTree/order/serializers.py
@@ -230,8 +230,20 @@ class PurchaseOrderCompleteSerializer(serializers.Serializer):
def save(self):
order = self.context['order']
+ order.complete_order()
+class PurchaseOrderIssueSerializer(serializers.Serializer):
+ """ Serializer for issuing (sending) a purchase order """
+
+ class Meta:
+ fields = []
+
+
+ def save(self):
+
+ order = self.context['order']
+ order.place_order()
class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html
index dc0c4344b1..ea1ab024ab 100644
--- a/InvenTree/order/templates/order/order_base.html
+++ b/InvenTree/order/templates/order/order_base.html
@@ -186,10 +186,14 @@ src="{% static 'img/blank_image.png' %}"
{% if order.status == PurchaseOrderStatus.PENDING %}
$("#place-order").click(function() {
- launchModalForm("{% url 'po-issue' order.id %}",
- {
- reload: true,
- });
+
+ issuePurchaseOrder(
+ {{ order.pk }},
+ {
+ reload: true,
+ }
+ );
+
});
{% endif %}
diff --git a/InvenTree/order/templates/order/order_issue.html b/InvenTree/order/templates/order/order_issue.html
deleted file mode 100644
index 058a7b529c..0000000000
--- a/InvenTree/order/templates/order/order_issue.html
+++ /dev/null
@@ -1,11 +0,0 @@
-{% extends "modal_form.html" %}
-
-{% load i18n %}
-
-{% block pre_form_content %}
-
-
- {% trans 'After placing this purchase order, line items will no longer be editable.' %}
-
-
-{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py
index 74c7976379..9536d96963 100644
--- a/InvenTree/order/urls.py
+++ b/InvenTree/order/urls.py
@@ -11,7 +11,6 @@ from . import views
purchase_order_detail_urls = [
- re_path(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'),
re_path(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'),
re_path(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'),
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index b36f64c937..4620c34210 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -113,33 +113,6 @@ class SalesOrderCancel(AjaxUpdateView):
order.cancel_order()
-class PurchaseOrderIssue(AjaxUpdateView):
- """ View for changing a purchase order from 'PENDING' to 'ISSUED' """
-
- model = PurchaseOrder
- ajax_form_title = _('Issue Order')
- ajax_template_name = "order/order_issue.html"
- form_class = order_forms.IssuePurchaseOrderForm
-
- def validate(self, order, form, **kwargs):
-
- confirm = str2bool(self.request.POST.get('confirm', False))
-
- if not confirm:
- form.add_error('confirm', _('Confirm order placement'))
-
- def save(self, order, form, **kwargs):
- """
- Once the form has been validated, place the order.
- """
- order.place_order()
-
- def get_data(self):
- return {
- 'success': _('Purchase order issued')
- }
-
-
class PurchaseOrderUpload(FileManagementFormView):
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js
index aedbb5a5bf..9fe50e3548 100644
--- a/InvenTree/templates/js/translated/order.js
+++ b/InvenTree/templates/js/translated/order.js
@@ -27,6 +27,7 @@
createSalesOrderShipment,
editPurchaseOrderLineItem,
exportOrder,
+ issurPurchaseOrder,
loadPurchaseOrderLineItemTable,
loadPurchaseOrderExtraLineTable
loadPurchaseOrderTable,
@@ -142,7 +143,9 @@ function completeShipment(shipment_id) {
});
}
-
+/*
+ * Launches a modal form to mark a PurchaseOrder as "complete"
+*/
function completePurchaseOrder(order_id, options={}) {
constructForm(
@@ -174,39 +177,75 @@ function completePurchaseOrder(order_id, options={}) {
return html;
},
onSuccess: function(response) {
- if (options.onSuccess) {
- options.onSuccess(response);
- }
+ handleFormSuccess(response, options);
}
}
);
}
+/*
+ * Launches a modal form to mark a PurchaseOrder as 'cancelled'
+ */
function cancelPurchaseOrder(order_id, options={}) {
- var html = `
-
- {% trans "Are you sure you wish to cancel this purchase order?" %}
- `;
-
constructForm(
`/api/order/po/${order_id}/cancel/`,
{
method: 'POST',
title: '{% trans "Cancel Purchase Order" %}',
confirm: true,
- preFormContent: html,
- onSuccess: function(response) {
- if (options.onSuccess) {
- options.onSuccess(response);
+ preFormContent: function(opts) {
+ var html = `
+
+ {% trans "Are you sure you wish to cancel this purchase order?" %}
+ `;
+
+ if (!opts.context.can_cancel) {
+ html += `
+
+ {% trans "This purchase order can not be cancelled" %}
+ `;
}
+
+ return html;
+ },
+ onSuccess: function(response) {
+ handleFormSuccess(response, options);
}
}
);
}
+/*
+ * Launches a modal form to mark a PurchaseOrder as "issued"
+ */
+function issuePurchaseOrder(order_id, options={}) {
+
+ constructForm(
+ `/api/order/po/${order_id}/issue/`,
+ {
+ method: 'POST',
+ title: '{% trans "Issue Purchase Order" %}',
+ confirm: true,
+ preFormContent: function(opts) {
+ var html = `
+
+ {% trans 'After placing this purchase order, line items will no longer be editable.' %}
+ `;
+
+ return html;
+ },
+ onSuccess: function(response) {
+ handleFormSuccess(response, options);
+ }
+ }
+ )
+
+}
+
+
// Open a dialog to create a new sales order shipment
function createSalesOrderShipment(options={}) {
From a510ca89f76c3305423dd5dd42498ca6b39d0bb9 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 15:55:21 +1000
Subject: [PATCH 31/47] Refactor CancelSalesOrder form
---
InvenTree/order/api.py | 58 +++++++------------
InvenTree/order/forms.py | 16 -----
InvenTree/order/serializers.py | 19 ++++++
.../templates/order/delete_attachment.html | 7 ---
.../templates/order/sales_order_base.html | 10 +++-
.../templates/order/sales_order_cancel.html | 12 ----
InvenTree/order/urls.py | 1 -
InvenTree/order/views.py | 26 ---------
InvenTree/templates/js/translated/order.js | 27 +++++++++
9 files changed, 74 insertions(+), 102 deletions(-)
delete mode 100644 InvenTree/order/templates/order/delete_attachment.html
delete mode 100644 InvenTree/order/templates/order/sales_order_cancel.html
diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py
index e93c5b093f..c54476a733 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -287,6 +287,7 @@ class PurchaseOrderDetail(generics.RetrieveUpdateDestroyAPIView):
class PurchaseOrderContextMixin:
+ """ Mixin to add purchase order object as serializer context variable """
def get_serializer_context(self):
""" Add the PurchaseOrder object to the serializer context """
@@ -871,13 +872,8 @@ class SalesOrderLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = serializers.SalesOrderLineItemSerializer
-class SalesOrderComplete(generics.CreateAPIView):
- """
- API endpoint for manually marking a SalesOrder as "complete".
- """
-
- queryset = models.SalesOrder.objects.all()
- serializer_class = serializers.SalesOrderCompleteSerializer
+class SalesOrderContextMixin:
+ """ Mixin to add sales order object as serializer context variable """
def get_serializer_context(self):
@@ -893,7 +889,22 @@ class SalesOrderComplete(generics.CreateAPIView):
return ctx
-class SalesOrderAllocateSerials(generics.CreateAPIView):
+class SalesOrderCancel(SalesOrderContextMixin, generics.CreateAPIView):
+
+ queryset = models.SalesOrder.objects.all()
+ serializer_class = serializers.SalesOrderCancelSerializer
+
+
+class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
+ """
+ API endpoint for manually marking a SalesOrder as "complete".
+ """
+
+ queryset = models.SalesOrder.objects.all()
+ serializer_class = serializers.SalesOrderCompleteSerializer
+
+
+class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
"""
API endpoint to allocation stock items against a SalesOrder,
by specifying serial numbers.
@@ -902,22 +913,8 @@ class SalesOrderAllocateSerials(generics.CreateAPIView):
queryset = models.SalesOrder.objects.none()
serializer_class = serializers.SalesOrderSerialAllocationSerializer
- def get_serializer_context(self):
- ctx = super().get_serializer_context()
-
- # Pass through the SalesOrder object to the serializer
- try:
- ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
- except:
- pass
-
- ctx['request'] = self.request
-
- return ctx
-
-
-class SalesOrderAllocate(generics.CreateAPIView):
+class SalesOrderAllocate(SalesOrderContextMixin, generics.CreateAPIView):
"""
API endpoint to allocate stock items against a SalesOrder
@@ -928,20 +925,6 @@ class SalesOrderAllocate(generics.CreateAPIView):
queryset = models.SalesOrder.objects.none()
serializer_class = serializers.SalesOrderShipmentAllocationSerializer
- def get_serializer_context(self):
-
- ctx = super().get_serializer_context()
-
- # Pass through the SalesOrder object to the serializer
- try:
- ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
- except:
- pass
-
- ctx['request'] = self.request
-
- return ctx
-
class SalesOrderAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
"""
@@ -1183,6 +1166,7 @@ order_api_urls = [
# Sales order detail view
re_path(r'^(?P\d+)/', include([
+ re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py
index 75b6cb94ec..a08cf81ab1 100644
--- a/InvenTree/order/forms.py
+++ b/InvenTree/order/forms.py
@@ -8,28 +8,12 @@ from __future__ import unicode_literals
from django import forms
from django.utils.translation import gettext_lazy as _
-from InvenTree.forms import HelperForm
from InvenTree.fields import InvenTreeMoneyField
from InvenTree.helpers import clean_decimal
from common.forms import MatchItemForm
-from .models import PurchaseOrder
-from .models import SalesOrder
-
-
-
-class CancelSalesOrderForm(HelperForm):
-
- confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Cancel order'))
-
- class Meta:
- model = SalesOrder
- fields = [
- 'confirm',
- ]
-
class OrderMatchItemForm(MatchItemForm):
""" Override MatchItemForm fields """
diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py
index 5bd753f17f..6f6654db1f 100644
--- a/InvenTree/order/serializers.py
+++ b/InvenTree/order/serializers.py
@@ -1041,6 +1041,25 @@ class SalesOrderCompleteSerializer(serializers.Serializer):
order.complete_order(user)
+class SalesOrderCancelSerializer(serializers.Serializer):
+ """ Serializer for marking a SalesOrder as cancelled
+ """
+
+ def get_context_data(self):
+
+ order = self.context['order']
+
+ return {
+ 'can_cancel': order.can_cancel(),
+ }
+
+ def save(self):
+
+ order = self.context['order']
+
+ order.cancel_order()
+
+
class SalesOrderSerialAllocationSerializer(serializers.Serializer):
"""
DRF serializer for allocation of serial numbers against a sales order / shipment
diff --git a/InvenTree/order/templates/order/delete_attachment.html b/InvenTree/order/templates/order/delete_attachment.html
deleted file mode 100644
index 4ee7f03cb1..0000000000
--- a/InvenTree/order/templates/order/delete_attachment.html
+++ /dev/null
@@ -1,7 +0,0 @@
-{% extends "modal_delete_form.html" %}
-{% load i18n %}
-
-{% block pre_form_content %}
-{% trans "Are you sure you want to delete this attachment?" %}
-
-{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html
index 9abd058996..5593918a38 100644
--- a/InvenTree/order/templates/order/sales_order_base.html
+++ b/InvenTree/order/templates/order/sales_order_base.html
@@ -224,9 +224,13 @@ $("#edit-order").click(function() {
});
$("#cancel-order").click(function() {
- launchModalForm("{% url 'so-cancel' order.id %}", {
- reload: true,
- });
+
+ cancelSalesOrder(
+ {{ order.pk }},
+ {
+ reload: true,
+ }
+ );
});
$("#complete-order").click(function() {
diff --git a/InvenTree/order/templates/order/sales_order_cancel.html b/InvenTree/order/templates/order/sales_order_cancel.html
deleted file mode 100644
index 2f0fe3beb1..0000000000
--- a/InvenTree/order/templates/order/sales_order_cancel.html
+++ /dev/null
@@ -1,12 +0,0 @@
-{% extends "modal_form.html" %}
-
-{% load i18n %}
-
-{% block pre_form_content %}
-
-
- {% trans "Warning" %}
- {% trans "Cancelling this order means that the order will no longer be editable." %}
-
-
-{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py
index 9536d96963..15e7f5b1bb 100644
--- a/InvenTree/order/urls.py
+++ b/InvenTree/order/urls.py
@@ -29,7 +29,6 @@ purchase_order_urls = [
]
sales_order_detail_urls = [
- re_path(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'),
re_path(r'^export/', views.SalesOrderExport.as_view(), name='so-export'),
re_path(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'),
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index 4620c34210..73c22aca22 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -87,32 +87,6 @@ class SalesOrderDetail(InvenTreeRoleMixin, DetailView):
template_name = 'order/sales_order_detail.html'
-class SalesOrderCancel(AjaxUpdateView):
- """ View for cancelling a sales order """
-
- model = SalesOrder
- ajax_form_title = _("Cancel sales order")
- ajax_template_name = "order/sales_order_cancel.html"
- form_class = order_forms.CancelSalesOrderForm
-
- def validate(self, order, form, **kwargs):
-
- confirm = str2bool(form.cleaned_data.get('confirm', False))
-
- if not confirm:
- form.add_error('confirm', _('Confirm order cancellation'))
-
- if not order.can_cancel():
- form.add_error(None, _('Order cannot be cancelled'))
-
- def save(self, order, form, **kwargs):
- """
- Once the form has been validated, cancel the SalesOrder
- """
-
- order.cancel_order()
-
-
class PurchaseOrderUpload(FileManagementFormView):
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js
index 9fe50e3548..6b163afa1e 100644
--- a/InvenTree/templates/js/translated/order.js
+++ b/InvenTree/templates/js/translated/order.js
@@ -21,6 +21,7 @@
/* exported
allocateStockToSalesOrder,
cancelPurchaseOrder,
+ cancelSalesOrder,
completePurchaseOrder,
completeShipment,
createSalesOrder,
@@ -246,6 +247,32 @@ function issuePurchaseOrder(order_id, options={}) {
}
+/*
+ * Launches a modal form to mark a SalesOrder as "cancelled"
+ */
+function cancelSalesOrder(order_id, options={}) {
+
+ constructForm(
+ `/api/order/so/${order_id}/cancel/`,
+ {
+ method: 'POST',
+ title: '{% trans "Cancel Sales Order" %}',
+ confirm: true,
+ preFormContent: function(opts) {
+ var html = `
+
+ {% trans "Cancelling this order means that the order will no longer be editable." %}
+ `;
+
+ return html;
+ },
+ onSuccess: function(response) {
+ handleFormSuccess(response, options);
+ }
+ }
+ );
+}
+
// Open a dialog to create a new sales order shipment
function createSalesOrderShipment(options={}) {
From 768e23c7b86009f651b78d16639998309fe0db8f Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 15:59:30 +1000
Subject: [PATCH 32/47] Refactor BuildOrder API classes with a fancy mixin
---
InvenTree/build/api.py | 111 +++++++++--------------------------------
1 file changed, 23 insertions(+), 88 deletions(-)
diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py
index 913a500d41..3d8d3f984c 100644
--- a/InvenTree/build/api.py
+++ b/InvenTree/build/api.py
@@ -233,7 +233,24 @@ class BuildUnallocate(generics.CreateAPIView):
return ctx
-class BuildOutputCreate(generics.CreateAPIView):
+class BuildOrderContextMixin:
+ """ Mixin class which adds build order as serializer context variable """
+
+ def get_serializer_context(self):
+ ctx = super().get_serializer_context()
+
+ ctx['request'] = self.request
+ ctx['to_complete'] = True
+
+ try:
+ ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
+ except:
+ pass
+
+ return ctx
+
+
+class BuildOutputCreate(BuildOrderContextMixin, generics.CreateAPIView):
"""
API endpoint for creating new build output(s)
"""
@@ -242,21 +259,8 @@ class BuildOutputCreate(generics.CreateAPIView):
serializer_class = build.serializers.BuildOutputCreateSerializer
- def get_serializer_context(self):
- ctx = super().get_serializer_context()
- ctx['request'] = self.request
- ctx['to_complete'] = True
-
- try:
- ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
- except:
- pass
-
- return ctx
-
-
-class BuildOutputComplete(generics.CreateAPIView):
+class BuildOutputComplete(BuildOrderContextMixin, generics.CreateAPIView):
"""
API endpoint for completing build outputs
"""
@@ -265,21 +269,8 @@ class BuildOutputComplete(generics.CreateAPIView):
serializer_class = build.serializers.BuildOutputCompleteSerializer
- def get_serializer_context(self):
- ctx = super().get_serializer_context()
- ctx['request'] = self.request
- ctx['to_complete'] = True
-
- try:
- ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
- except:
- pass
-
- return ctx
-
-
-class BuildOutputDelete(generics.CreateAPIView):
+class BuildOutputDelete(BuildOrderContextMixin, generics.CreateAPIView):
"""
API endpoint for deleting multiple build outputs
"""
@@ -288,20 +279,8 @@ class BuildOutputDelete(generics.CreateAPIView):
serializer_class = build.serializers.BuildOutputDeleteSerializer
- def get_serializer_context(self):
- ctx = super().get_serializer_context()
- ctx['request'] = self.request
-
- try:
- ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
- except:
- pass
-
- return ctx
-
-
-class BuildFinish(generics.CreateAPIView):
+class BuildFinish(BuildOrderContextMixin, generics.CreateAPIView):
"""
API endpoint for marking a build as finished (completed)
"""
@@ -310,20 +289,8 @@ class BuildFinish(generics.CreateAPIView):
serializer_class = build.serializers.BuildCompleteSerializer
- def get_serializer_context(self):
- ctx = super().get_serializer_context()
- ctx['request'] = self.request
-
- try:
- ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
- except:
- pass
-
- return ctx
-
-
-class BuildAutoAllocate(generics.CreateAPIView):
+class BuildAutoAllocate(BuildOrderContextMixin, generics.CreateAPIView):
"""
API endpoint for 'automatically' allocating stock against a build order.
@@ -337,24 +304,8 @@ class BuildAutoAllocate(generics.CreateAPIView):
serializer_class = build.serializers.BuildAutoAllocationSerializer
- def get_serializer_context(self):
- """
- Provide the Build object to the serializer context
- """
- context = super().get_serializer_context()
-
- try:
- context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
- except:
- pass
-
- context['request'] = self.request
-
- return context
-
-
-class BuildAllocate(generics.CreateAPIView):
+class BuildAllocate(BuildOrderContextMixin, generics.CreateAPIView):
"""
API endpoint to allocate stock items to a build order
@@ -370,22 +321,6 @@ class BuildAllocate(generics.CreateAPIView):
serializer_class = build.serializers.BuildAllocationSerializer
- def get_serializer_context(self):
- """
- Provide the Build object to the serializer context
- """
-
- context = super().get_serializer_context()
-
- try:
- context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
- except:
- pass
-
- context['request'] = self.request
-
- return context
-
class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView):
"""
From bd3d6f47a12efeba8601eadab4451ce52370944b Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 16:30:46 +1000
Subject: [PATCH 33/47] Refactor CancelBuild form
---
InvenTree/build/api.py | 8 +++
InvenTree/build/forms.py | 19 -------
InvenTree/build/models.py | 49 +++++++++++++++++--
InvenTree/build/serializers.py | 46 +++++++++++++++++
.../build/templates/build/build_base.html | 12 +++--
InvenTree/build/templates/build/cancel.html | 7 ---
InvenTree/build/test_build.py | 2 +-
InvenTree/build/tests.py | 2 +-
InvenTree/build/urls.py | 1 -
InvenTree/build/views.py | 31 ------------
InvenTree/templates/js/translated/build.js | 44 +++++++++++++++++
11 files changed, 153 insertions(+), 68 deletions(-)
delete mode 100644 InvenTree/build/templates/build/cancel.html
diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py
index 3d8d3f984c..e32b404ae2 100644
--- a/InvenTree/build/api.py
+++ b/InvenTree/build/api.py
@@ -322,6 +322,13 @@ class BuildAllocate(BuildOrderContextMixin, generics.CreateAPIView):
serializer_class = build.serializers.BuildAllocationSerializer
+class BuildCancel(BuildOrderContextMixin, generics.CreateAPIView):
+ """ API endpoint for cancelling a BuildOrder """
+
+ queryset = Build.objects.all()
+ serializer_class = build.serializers.BuildCancelSerializer
+
+
class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detail view of a BuildItem object
@@ -462,6 +469,7 @@ build_api_urls = [
re_path(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
re_path(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
re_path(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
+ re_path(r'^cancel/', BuildCancel.as_view(), name='api-build-cancel'),
re_path(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
re_path(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
])),
diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py
index 08714b0f3c..77a42571d8 100644
--- a/InvenTree/build/forms.py
+++ b/InvenTree/build/forms.py
@@ -5,22 +5,3 @@ Django Forms for interacting with Build objects
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
-
-from django.utils.translation import gettext_lazy as _
-from django import forms
-
-from InvenTree.forms import HelperForm
-
-from .models import Build
-
-
-class CancelBuildForm(HelperForm):
- """ Form for cancelling a build """
-
- confirm_cancel = forms.BooleanField(required=False, label=_('Confirm cancel'), help_text=_('Confirm build cancellation'))
-
- class Meta:
- model = Build
- fields = [
- 'confirm_cancel'
- ]
diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index 43bca0e238..464ef33f8d 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -479,6 +479,16 @@ class Build(MPTTModel, ReferenceIndexingMixin):
return outputs
+ @property
+ def complete_count(self):
+
+ quantity = 0
+
+ for output in self.complete_outputs:
+ quantity += output.quantity
+
+ return quantity
+
@property
def incomplete_outputs(self):
"""
@@ -588,7 +598,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
trigger_event('build.completed', id=self.pk)
@transaction.atomic
- def cancelBuild(self, user):
+ def cancel_build(self, user, **kwargs):
""" Mark the Build as CANCELLED
- Delete any pending BuildItem objects (but do not remove items from stock)
@@ -596,8 +606,23 @@ class Build(MPTTModel, ReferenceIndexingMixin):
- Save the Build object
"""
- for item in self.allocated_stock.all():
- item.delete()
+ remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
+ remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
+
+ # Handle stock allocations
+ for build_item in self.allocated_stock.all():
+
+ if remove_allocated_stock:
+ build_item.complete_allocation(user)
+
+ build_item.delete()
+
+ # Remove incomplete outputs (if required)
+ if remove_incomplete_outputs:
+ outputs = self.build_outputs.filter(is_building=True)
+
+ for output in outputs:
+ output.delete()
# Date of 'completion' is the date the build was cancelled
self.completion_date = datetime.now().date()
@@ -1025,6 +1050,24 @@ class Build(MPTTModel, ReferenceIndexingMixin):
# All parts must be fully allocated!
return True
+ def is_partially_allocated(self, output):
+ """
+ Returns True if the particular build output is (at least) partially allocated
+ """
+
+ # If output is not specified, we are talking about "untracked" items
+ if output is None:
+ bom_items = self.untracked_bom_items
+ else:
+ bom_items = self.tracked_bom_items
+
+ for bom_item in bom_items:
+
+ if self.allocated_quantity(bom_item, output) > 0:
+ return True
+
+ return False
+
def are_untracked_parts_allocated(self):
"""
Returns True if the un-tracked parts are fully allocated for this BuildOrder
diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py
index bed4b59203..0f1703750c 100644
--- a/InvenTree/build/serializers.py
+++ b/InvenTree/build/serializers.py
@@ -438,6 +438,52 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
)
+class BuildCancelSerializer(serializers.Serializer):
+
+ class Meta:
+ fields = [
+ 'remove_allocated_stock',
+ 'remove_incomplete_outputs',
+ ]
+
+ def get_context_data(self):
+
+ build = self.context['build']
+
+ return {
+ 'has_allocated_stock': build.is_partially_allocated(None),
+ 'incomplete_outputs': build.incomplete_count,
+ 'completed_outputs': build.complete_count,
+ }
+
+ remove_allocated_stock = serializers.BooleanField(
+ label=_('Remove Allocated Stock'),
+ help_text=_('Subtract any stock which has already been allocated to this build'),
+ required=False,
+ default=False,
+ )
+
+ remove_incomplete_outputs = serializers.BooleanField(
+ label=_('Remove Incomplete Outputs'),
+ help_text=_('Delete any build outputs which have not been completed'),
+ required=False,
+ default=False,
+ )
+
+ def save(self):
+
+ build = self.context['build']
+ request = self.context['request']
+
+ data = self.validated_data
+
+ build.cancel_build(
+ request.user,
+ remove_allocated_stock=data.get('remove_unallocated_stock', False),
+ remove_incomplete_outputs=data.get('remove_incomplete_outputs', False),
+ )
+
+
class BuildCompleteSerializer(serializers.Serializer):
"""
DRF serializer for marking a BuildOrder as complete
diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html
index 4d2c77278c..558c3dc3f2 100644
--- a/InvenTree/build/templates/build/build_base.html
+++ b/InvenTree/build/templates/build/build_base.html
@@ -214,11 +214,13 @@ src="{% static 'img/blank_image.png' %}"
});
$("#build-cancel").click(function() {
- launchModalForm("{% url 'build-cancel' build.id %}",
- {
- reload: true,
- submit_text: '{% trans "Cancel Build" %}',
- });
+
+ cancelBuildOrder(
+ {{ build.pk }},
+ {
+ reload: true,
+ }
+ );
});
$("#build-complete").on('click', function() {
diff --git a/InvenTree/build/templates/build/cancel.html b/InvenTree/build/templates/build/cancel.html
deleted file mode 100644
index 48d8ca09bd..0000000000
--- a/InvenTree/build/templates/build/cancel.html
+++ /dev/null
@@ -1,7 +0,0 @@
-{% extends "modal_form.html" %}
-{% load i18n %}
-{% block pre_form_content %}
-
-{% trans "Are you sure you wish to cancel this build?" %}
-
-{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py
index 914cef29ab..e2cda00ec9 100644
--- a/InvenTree/build/test_build.py
+++ b/InvenTree/build/test_build.py
@@ -304,7 +304,7 @@ class BuildTest(BuildTestBase):
"""
self.allocate_stock(50, 50, 200, self.output_1)
- self.build.cancelBuild(None)
+ self.build.cancel_build(None)
self.assertEqual(BuildItem.objects.count(), 0)
"""
diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py
index 7afd078ce9..27b7720973 100644
--- a/InvenTree/build/tests.py
+++ b/InvenTree/build/tests.py
@@ -107,7 +107,7 @@ class BuildTestSimple(TestCase):
self.assertEqual(build.status, BuildStatus.PENDING)
- build.cancelBuild(self.user)
+ build.cancel_build(self.user)
self.assertEqual(build.status, BuildStatus.CANCELLED)
diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py
index 520d97ef6a..0788a1de37 100644
--- a/InvenTree/build/urls.py
+++ b/InvenTree/build/urls.py
@@ -7,7 +7,6 @@ from django.urls import include, re_path
from . import views
build_detail_urls = [
- re_path(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
re_path(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index 2b8629afe9..ff12d2f211 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -43,37 +43,6 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
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 BuildDetail(InvenTreeRoleMixin, DetailView):
"""
Detail view of a single Build object.
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index d68b319a25..a636cfeec8 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -21,6 +21,7 @@
/* exported
allocateStockToBuild,
autoAllocateStockToBuild,
+ cancelBuildOrder,
completeBuildOrder,
createBuildOutput,
editBuildOrder,
@@ -123,6 +124,49 @@ function newBuildOrder(options={}) {
}
+/* Construct a form to cancel a build order */
+function cancelBuildOrder(build_id, options={}) {
+
+ constructForm(
+ `/api/build/${build_id}/cancel/`,
+ {
+ method: 'POST',
+ title: '{% trans "Cancel Build Order" %}',
+ confirm: true,
+ fields: {
+ remove_allocated_stock: {},
+ remove_incomplete_outputs: {},
+ },
+ preFormContent: function(opts) {
+ var html = `
+
+ {% trans "Are you sure you wish to cancel this build?" %}
+ `;
+
+ if (opts.context.has_allocated_stock) {
+ html += `
+
+ {% trans "Stock items have been allocated to this build order" %}
+ `;
+ }
+
+ if (opts.context.incomplete_outputs) {
+ html += `
+
+ {% trans "There are incomplete outputs remaining for this build order" %}
+ `;
+ }
+
+ return html;
+ },
+ onSuccess: function(response) {
+ handleFormSuccess(response, options);
+ }
+ }
+ );
+}
+
+
/* Construct a form to "complete" (finish) a build order */
function completeBuildOrder(build_id, options={}) {
From 5cf30a850dfd040ad857d2eb040c705055fa9b55 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 17:00:37 +1000
Subject: [PATCH 34/47] Prune a lot of dead code
---
InvenTree/part/forms.py | 18 -
InvenTree/part/views.py | 39 --
InvenTree/stock/forms.py | 170 --------
InvenTree/stock/views.py | 818 ---------------------------------------
4 files changed, 1045 deletions(-)
diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index 2ba60d111b..87210901d2 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -95,24 +95,6 @@ class EditPartParameterTemplateForm(HelperForm):
]
-class EditCategoryForm(HelperForm):
- """ Form for editing a PartCategory object """
-
- field_prefix = {
- 'default_keywords': 'fa-key',
- }
-
- class Meta:
- model = PartCategory
- fields = [
- 'parent',
- 'name',
- 'description',
- 'default_location',
- 'default_keywords',
- ]
-
-
class EditCategoryParameterTemplateForm(HelperForm):
""" Form for editing a PartCategoryParameterTemplate object """
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 41e734ce2a..efaf83ae95 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -1001,45 +1001,6 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
return context
-class CategoryEdit(AjaxUpdateView):
- """
- Update view to edit a PartCategory
- """
-
- model = PartCategory
- form_class = part_forms.EditCategoryForm
- ajax_template_name = 'modal_form.html'
- ajax_form_title = _('Edit Part Category')
-
- def get_context_data(self, **kwargs):
- context = super(CategoryEdit, self).get_context_data(**kwargs).copy()
-
- try:
- context['category'] = self.get_object()
- except:
- pass
-
- return context
-
- def get_form(self):
- """ Customize form data for PartCategory editing.
-
- Limit the choices for 'parent' field to those which make sense
- """
-
- form = super(AjaxUpdateView, self).get_form()
-
- category = self.get_object()
-
- # Remove any invalid choices for the parent category part
- parent_choices = PartCategory.objects.all()
- parent_choices = parent_choices.exclude(id__in=category.getUniqueChildren())
-
- form.fields['parent'].queryset = parent_choices
-
- return form
-
-
class CategoryDelete(AjaxDeleteView):
"""
Delete view to delete a PartCategory
diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index 3860936ae7..8e0017b3fd 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -32,23 +32,6 @@ class ReturnStockItemForm(HelperForm):
]
-class EditStockLocationForm(HelperForm):
- """
- Form for editing a StockLocation
-
- TODO: Migrate this form to the modern API forms interface
- """
-
- class Meta:
- model = StockLocation
- fields = [
- 'name',
- 'parent',
- 'description',
- 'owner',
- ]
-
-
class ConvertStockItemForm(HelperForm):
"""
Form for converting a StockItem to a variant of its current part.
@@ -63,159 +46,6 @@ class ConvertStockItemForm(HelperForm):
]
-class CreateStockItemForm(HelperForm):
- """
- Form for creating a new StockItem
-
- TODO: Migrate this form to the modern API forms interface
- """
-
- expiry_date = DatePickerFormField(
- label=_('Expiry Date'),
- help_text=_('Expiration date for this stock item'),
- )
-
- serial_numbers = forms.CharField(label=_('Serial Numbers'), required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
-
- def __init__(self, *args, **kwargs):
-
- self.field_prefix = {
- 'serial_numbers': 'fa-hashtag',
- 'link': 'fa-link',
- }
-
- super().__init__(*args, **kwargs)
-
- class Meta:
- model = StockItem
- fields = [
- 'part',
- 'supplier_part',
- 'location',
- 'quantity',
- 'batch',
- 'serial_numbers',
- 'packaging',
- 'purchase_price',
- 'expiry_date',
- 'link',
- 'delete_on_deplete',
- 'status',
- 'owner',
- ]
-
- # Custom clean to prevent complex StockItem.clean() logic from running (yet)
- def full_clean(self):
- self._errors = ErrorDict()
-
- if not self.is_bound: # Stop further processing.
- return
-
- self.cleaned_data = {}
-
- # If the form is permitted to be empty, and none of the form data has
- # changed from the initial data, short circuit any validation.
- if self.empty_permitted and not self.has_changed():
- return
-
- # Don't run _post_clean() as this will run StockItem.clean()
- self._clean_fields()
- self._clean_form()
-
-
-class SerializeStockForm(HelperForm):
- """
- Form for serializing a StockItem.
-
- TODO: Migrate this form to the modern API forms interface
- """
-
- destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination for serialized stock (by default, will remain in current location)'))
-
- serial_numbers = forms.CharField(label=_('Serial numbers'), required=True, help_text=_('Unique serial numbers (must match quantity)'))
-
- note = forms.CharField(label=_('Notes'), required=False, help_text=_('Add transaction note (optional)'))
-
- quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
-
- def __init__(self, *args, **kwargs):
-
- # Extract the stock item
- item = kwargs.pop('item', None)
-
- if item:
- self.field_placeholder['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
-
- super().__init__(*args, **kwargs)
-
- class Meta:
- model = StockItem
-
- fields = [
- 'quantity',
- 'serial_numbers',
- 'destination',
- 'note',
- ]
-
-
-class UninstallStockForm(forms.ModelForm):
- """
- Form for uninstalling a stock item which is installed in another item.
-
- TODO: Migrate this form to the modern API forms interface
- """
-
- location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Destination location for uninstalled items'))
-
- note = forms.CharField(label=_('Notes'), required=False, help_text=_('Add transaction note (optional)'))
-
- confirm = forms.BooleanField(required=False, initial=False, label=_('Confirm uninstall'), help_text=_('Confirm removal of installed stock items'))
-
- class Meta:
-
- model = StockItem
-
- fields = [
- 'location',
- 'note',
- 'confirm',
- ]
-
-
-class EditStockItemForm(HelperForm):
- """ Form for editing a StockItem object.
- Note that not all fields can be edited here (even if they can be specified during creation.
-
- location - Must be updated in a 'move' transaction
- quantity - Must be updated in a 'stocktake' transaction
- part - Cannot be edited after creation
-
- TODO: Migrate this form to the modern API forms interface
- """
-
- expiry_date = DatePickerFormField(
- label=_('Expiry Date'),
- help_text=_('Expiration date for this stock item'),
- )
-
- class Meta:
- model = StockItem
-
- fields = [
- 'supplier_part',
- 'serial',
- 'batch',
- 'status',
- 'expiry_date',
- 'purchase_price',
- 'packaging',
- 'link',
- 'delete_on_deplete',
- 'owner',
- ]
-
-
class TrackingEntryForm(HelperForm):
"""
Form for creating / editing a StockItemTracking object.
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 3bca77ec9e..1b9aba04c8 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -19,7 +19,6 @@ from django.utils.translation import gettext_lazy as _
from moneyed import CURRENCIES
-from InvenTree.views import AjaxView
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
from InvenTree.views import QRCodeView
from InvenTree.views import InvenTreeRoleMixin
@@ -135,139 +134,6 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
return super().get(request, *args, **kwargs)
-class StockLocationEdit(AjaxUpdateView):
- """
- View for editing details of a StockLocation.
- This view is used with the EditStockLocationForm to deliver a modal form to the web view
-
- TODO: Remove this code as location editing has been migrated to the API forms
- - Have to still validate that all form functionality (as below) as been ported
-
- """
-
- model = StockLocation
- form_class = StockForms.EditStockLocationForm
- context_object_name = 'location'
- ajax_template_name = 'modal_form.html'
- ajax_form_title = _('Edit Stock Location')
-
- def get_form(self):
- """ Customize form data for StockLocation editing.
-
- Limit the choices for 'parent' field to those which make sense.
- If ownership control is enabled and location has parent, disable owner field.
- """
-
- form = super(AjaxUpdateView, self).get_form()
-
- location = self.get_object()
-
- # Remove any invalid choices for the 'parent' field
- parent_choices = StockLocation.objects.all()
- parent_choices = parent_choices.exclude(id__in=location.getUniqueChildren())
-
- form.fields['parent'].queryset = parent_choices
-
- # Is ownership control enabled?
- stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
-
- if not stock_ownership_control:
- # Hide owner field
- form.fields['owner'].widget = HiddenInput()
- else:
- # Get location's owner
- location_owner = location.owner
-
- if location_owner:
- if location.parent:
- try:
- # If location has parent and owner: automatically select parent's owner
- parent_owner = location.parent.owner
- form.fields['owner'].initial = parent_owner
- except AttributeError:
- pass
- else:
- # If current owner exists: automatically select it
- form.fields['owner'].initial = location_owner
-
- # Update queryset or disable field (only if not admin)
- if not self.request.user.is_superuser:
- if type(location_owner.owner) is Group:
- user_as_owner = Owner.get_owner(self.request.user)
- queryset = location_owner.get_related_owners(include_group=True)
-
- if user_as_owner not in queryset:
- # Only owners or admin can change current owner
- form.fields['owner'].disabled = True
- else:
- form.fields['owner'].queryset = queryset
-
- return form
-
- def save(self, object, form, **kwargs):
- """ If location has children and ownership control is enabled:
- - update owner of all children location of this location
- - update owner for all stock items at this location
- """
-
- self.object = form.save()
-
- # Is ownership control enabled?
- stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
-
- if stock_ownership_control and self.object.owner:
- # Get authorized users
- authorized_owners = self.object.owner.get_related_owners()
-
- # Update children locations
- children_locations = self.object.get_children()
- for child in children_locations:
- # Check if current owner is subset of new owner
- if child.owner and authorized_owners:
- if child.owner in authorized_owners:
- continue
-
- child.owner = self.object.owner
- child.save()
-
- # Update stock items
- stock_items = self.object.get_stock_items()
-
- for stock_item in stock_items:
- # Check if current owner is subset of new owner
- if stock_item.owner and authorized_owners:
- if stock_item.owner in authorized_owners:
- continue
-
- stock_item.owner = self.object.owner
- stock_item.save()
-
- return self.object
-
- def validate(self, item, form):
- """ Check that owner is set if stock ownership control is enabled """
-
- parent = form.cleaned_data.get('parent', None)
-
- owner = form.cleaned_data.get('owner', None)
-
- # Is ownership control enabled?
- stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
-
- if stock_ownership_control:
- if not owner and not self.request.user.is_superuser:
- form.add_error('owner', _('Owner is required (ownership control is enabled)'))
- else:
- try:
- if parent.owner:
- if parent.owner != owner:
- error = f'Owner requires to be equivalent to parent\'s owner ({parent.owner})'
- form.add_error('owner', error)
- except AttributeError:
- # No parent
- pass
-
-
class StockLocationQRCode(QRCodeView):
""" View for displaying a QR code for a StockLocation object """
@@ -366,261 +232,6 @@ class StockItemQRCode(QRCodeView):
return None
-class StockItemUninstall(AjaxView, FormMixin):
- """
- View for uninstalling one or more StockItems,
- which are installed in another stock item.
-
- Stock items are uninstalled into a location,
- defaulting to the location that they were "in" before they were installed.
-
- If multiple default locations are detected,
- leave the final location up to the user.
- """
-
- ajax_template_name = 'stock/stock_uninstall.html'
- ajax_form_title = _('Uninstall Stock Items')
- form_class = StockForms.UninstallStockForm
- role_required = 'stock.change'
-
- # List of stock items to uninstall (initially empty)
- stock_items = []
-
- def get_stock_items(self):
-
- return self.stock_items
-
- def get_initial(self):
-
- initials = super().get_initial().copy()
-
- # Keep track of the current locations of stock items
- current_locations = set()
-
- # Keep track of the default locations for stock items
- default_locations = set()
-
- for item in self.stock_items:
-
- if item.location:
- current_locations.add(item.location)
-
- if item.part.default_location:
- default_locations.add(item.part.default_location)
-
- if len(current_locations) == 1:
- # If the selected stock items are currently in a single location,
- # select that location as the destination.
- initials['location'] = next(iter(current_locations))
- elif len(current_locations) == 0:
- # There are no current locations set
- if len(default_locations) == 1:
- # Select the single default location
- initials['location'] = next(iter(default_locations))
-
- return initials
-
- def get(self, request, *args, **kwargs):
-
- """ Extract list of stock items, which are supplied as a list,
- e.g. items[]=1,2,3
- """
-
- if 'items[]' in request.GET:
- self.stock_items = StockItem.objects.filter(id__in=request.GET.getlist('items[]'))
- else:
- self.stock_items = []
-
- return self.renderJsonResponse(request, self.get_form())
-
- def post(self, request, *args, **kwargs):
-
- """
- Extract a list of stock items which are included as hidden inputs in the form data.
- """
-
- items = []
-
- for item in self.request.POST:
- if item.startswith('stock-item-'):
- pk = item.replace('stock-item-', '')
-
- try:
- stock_item = StockItem.objects.get(pk=pk)
- items.append(stock_item)
- except (ValueError, StockItem.DoesNotExist):
- pass
-
- self.stock_items = items
-
- # Assume the form is valid, until it isn't!
- valid = True
-
- confirmed = str2bool(request.POST.get('confirm'))
-
- note = request.POST.get('note', '')
-
- location = request.POST.get('location', None)
-
- if location:
- try:
- location = StockLocation.objects.get(pk=location)
- except (ValueError, StockLocation.DoesNotExist):
- location = None
-
- if not location:
- # Location is required!
- valid = False
-
- form = self.get_form()
-
- if not confirmed:
- valid = False
- form.add_error('confirm', _('Confirm stock adjustment'))
-
- data = {
- 'form_valid': valid,
- }
-
- if valid:
- # Ok, now let's actually uninstall the stock items
- for item in self.stock_items:
- item.uninstallIntoLocation(location, request.user, note)
-
- data['success'] = _('Uninstalled stock items')
-
- return self.renderJsonResponse(request, form=form, data=data)
-
- def get_context_data(self):
-
- context = super().get_context_data()
-
- context['stock_items'] = self.get_stock_items()
-
- return context
-
-
-class StockItemEdit(AjaxUpdateView):
- """
- View for editing details of a single StockItem
- """
-
- model = StockItem
- form_class = StockForms.EditStockItemForm
- context_object_name = 'item'
- ajax_template_name = 'modal_form.html'
- ajax_form_title = _('Edit Stock Item')
-
- def get_form(self):
- """ Get form for StockItem editing.
-
- Limit the choices for supplier_part
- """
-
- form = super(AjaxUpdateView, self).get_form()
-
- # Hide the "expiry date" field if the feature is not enabled
- if not common.settings.stock_expiry_enabled():
- form.fields['expiry_date'].widget = HiddenInput()
-
- item = self.get_object()
-
- # If the part cannot be purchased, hide the supplier_part field
- if not item.part.purchaseable:
- form.fields['supplier_part'].widget = HiddenInput()
-
- form.fields.pop('purchase_price')
- else:
- query = form.fields['supplier_part'].queryset
- query = query.filter(part=item.part.id)
- form.fields['supplier_part'].queryset = query
-
- # Hide the serial number field if it is not required
- if not item.part.trackable and not item.serialized:
- form.fields['serial'].widget = HiddenInput()
-
- location = item.location
-
- # Is ownership control enabled?
- stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
-
- if not stock_ownership_control:
- form.fields['owner'].widget = HiddenInput()
- else:
- try:
- location_owner = location.owner
- except AttributeError:
- location_owner = None
-
- # Check if location has owner
- if location_owner:
- form.fields['owner'].initial = location_owner
-
- # Check location's owner type and filter potential owners
- if type(location_owner.owner) is Group:
- user_as_owner = Owner.get_owner(self.request.user)
- queryset = location_owner.get_related_owners(include_group=True)
-
- if user_as_owner in queryset:
- form.fields['owner'].initial = user_as_owner
-
- form.fields['owner'].queryset = queryset
-
- elif type(location_owner.owner) is get_user_model():
- # If location's owner is a user: automatically set owner field and disable it
- form.fields['owner'].disabled = True
- form.fields['owner'].initial = location_owner
-
- try:
- item_owner = item.owner
- except AttributeError:
- item_owner = None
-
- # Check if item has owner
- if item_owner:
- form.fields['owner'].initial = item_owner
-
- # Check item's owner type and filter potential owners
- if type(item_owner.owner) is Group:
- user_as_owner = Owner.get_owner(self.request.user)
- queryset = item_owner.get_related_owners(include_group=True)
-
- if user_as_owner in queryset:
- form.fields['owner'].initial = user_as_owner
-
- form.fields['owner'].queryset = queryset
-
- elif type(item_owner.owner) is get_user_model():
- # If item's owner is a user: automatically set owner field and disable it
- form.fields['owner'].disabled = True
- form.fields['owner'].initial = item_owner
-
- return form
-
- def validate(self, item, form):
- """ Check that owner is set if stock ownership control is enabled """
-
- owner = form.cleaned_data.get('owner', None)
-
- # Is ownership control enabled?
- stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
-
- if stock_ownership_control:
- if not owner and not self.request.user.is_superuser:
- form.add_error('owner', _('Owner is required (ownership control is enabled)'))
-
- def save(self, object, form, **kwargs):
- """
- Override the save method, to track the user who updated the model
- """
-
- item = form.save(commit=False)
-
- item.save(user=self.request.user)
-
- return item
-
-
class StockItemConvert(AjaxUpdateView):
"""
View for 'converting' a StockItem to a variant of its current part.
@@ -655,435 +266,6 @@ class StockItemConvert(AjaxUpdateView):
return stock_item
-class StockLocationCreate(AjaxCreateView):
- """
- View for creating a new StockLocation
- A parent location (another StockLocation object) can be passed as a query parameter
-
- TODO: Remove this class entirely, as it has been migrated to the API forms
- - Still need to check that all the functionality (as below) has been implemented
-
- """
-
- model = StockLocation
- form_class = StockForms.EditStockLocationForm
- context_object_name = 'location'
- ajax_template_name = 'modal_form.html'
- ajax_form_title = _('Create new Stock Location')
-
- def get_initial(self):
- initials = super(StockLocationCreate, self).get_initial().copy()
-
- loc_id = self.request.GET.get('location', None)
-
- if loc_id:
- try:
- initials['parent'] = StockLocation.objects.get(pk=loc_id)
- except StockLocation.DoesNotExist:
- pass
-
- return initials
-
- def get_form(self):
- """ Disable owner field when:
- - creating child location
- - and stock ownership control is enable
- """
-
- form = super().get_form()
-
- # Is ownership control enabled?
- stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
-
- if not stock_ownership_control:
- # Hide owner field
- form.fields['owner'].widget = HiddenInput()
- else:
- # If user did not selected owner: automatically match to parent's owner
- if not form['owner'].data:
- try:
- parent_id = form['parent'].value()
- parent = StockLocation.objects.get(pk=parent_id)
-
- if parent:
- form.fields['owner'].initial = parent.owner
- if not self.request.user.is_superuser:
- form.fields['owner'].disabled = True
- except StockLocation.DoesNotExist:
- pass
- except ValueError:
- pass
-
- return form
-
- def save(self, form):
- """ If parent location exists then use it to set the owner """
-
- self.object = form.save(commit=False)
-
- parent = form.cleaned_data.get('parent', None)
-
- if parent:
- # Select parent's owner
- self.object.owner = parent.owner
-
- self.object.save()
-
- return self.object
-
- def validate(self, item, form):
- """ Check that owner is set if stock ownership control is enabled """
-
- parent = form.cleaned_data.get('parent', None)
-
- owner = form.cleaned_data.get('owner', None)
-
- # Is ownership control enabled?
- stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
-
- if stock_ownership_control:
- if not owner and not self.request.user.is_superuser:
- form.add_error('owner', _('Owner is required (ownership control is enabled)'))
- else:
- try:
- if parent.owner:
- if parent.owner != owner:
- error = f'Owner requires to be equivalent to parent\'s owner ({parent.owner})'
- form.add_error('owner', error)
- except AttributeError:
- # No parent
- pass
-
-
-class StockItemCreate(AjaxCreateView):
- """
- View for creating a new StockItem
- Parameters can be pre-filled by passing query items:
- - part: The part of which the new StockItem is an instance
- - location: The location of the new StockItem
-
- If the parent part is a "tracked" part, provide an option to create uniquely serialized items
- rather than a bulk quantity of stock items
- """
-
- model = StockItem
- form_class = StockForms.CreateStockItemForm
- context_object_name = 'item'
- ajax_template_name = 'modal_form.html'
- ajax_form_title = _('Create new Stock Item')
-
- def get_part(self, form=None):
- """
- Attempt to get the "part" associted with this new stockitem.
-
- - May be passed to the form as a query parameter (e.g. ?part=)
- - May be passed via the form field itself.
- """
-
- # Try to extract from the URL query
- part_id = self.request.GET.get('part', None)
-
- if part_id:
- try:
- part = Part.objects.get(pk=part_id)
- return part
- except (Part.DoesNotExist, ValueError):
- pass
-
- # Try to get from the form
- if form:
- try:
- part_id = form['part'].value()
- part = Part.objects.get(pk=part_id)
- return part
- except (Part.DoesNotExist, ValueError):
- pass
-
- # Could not extract a part object
- return None
-
- def get_form(self):
- """ Get form for StockItem creation.
- Overrides the default get_form() method to intelligently limit
- ForeignKey choices based on other selections
- """
-
- form = super().get_form()
-
- # Hide the "expiry date" field if the feature is not enabled
- if not common.settings.stock_expiry_enabled():
- form.fields['expiry_date'].widget = HiddenInput()
-
- part = self.get_part(form=form)
-
- if part is not None:
-
- # Add placeholder text for the serial number field
- form.field_placeholder['serial_numbers'] = part.getSerialNumberString()
-
- form.rebuild_layout()
-
- if not part.purchaseable:
- form.fields.pop('purchase_price')
-
- # Hide the 'part' field (as a valid part is selected)
- # form.fields['part'].widget = HiddenInput()
-
- # Trackable parts get special consideration:
- if part.trackable:
- form.fields['delete_on_deplete'].disabled = True
- else:
- form.fields['serial_numbers'].disabled = True
-
- # If the part is NOT purchaseable, hide the supplier_part field
- if not part.purchaseable:
- form.fields['supplier_part'].widget = HiddenInput()
- else:
- # Pre-select the allowable SupplierPart options
- parts = form.fields['supplier_part'].queryset
- parts = parts.filter(part=part.id)
-
- form.fields['supplier_part'].queryset = parts
-
- # If there is one (and only one) supplier part available, pre-select it
- all_parts = parts.all()
-
- if len(all_parts) == 1:
-
- # TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
- form.fields['supplier_part'].initial = all_parts[0].id
-
- else:
- # No Part has been selected!
- # We must not provide *any* options for SupplierPart
- form.fields['supplier_part'].queryset = SupplierPart.objects.none()
-
- form.fields['serial_numbers'].disabled = True
-
- # Otherwise if the user has selected a SupplierPart, we know what Part they meant!
- if form['supplier_part'].value() is not None:
- pass
-
- location = None
- try:
- loc_id = form['location'].value()
- location = StockLocation.objects.get(pk=loc_id)
- except StockLocation.DoesNotExist:
- pass
- except ValueError:
- pass
-
- # Is ownership control enabled?
- stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
- if not stock_ownership_control:
- form.fields['owner'].widget = HiddenInput()
- else:
- try:
- location_owner = location.owner
- except AttributeError:
- location_owner = None
-
- if location_owner:
- # Check location's owner type and filter potential owners
- if type(location_owner.owner) is Group:
- user_as_owner = Owner.get_owner(self.request.user)
- queryset = location_owner.get_related_owners()
-
- if user_as_owner in queryset:
- form.fields['owner'].initial = user_as_owner
-
- form.fields['owner'].queryset = queryset
-
- elif type(location_owner.owner) is get_user_model():
- # If location's owner is a user: automatically set owner field and disable it
- form.fields['owner'].disabled = True
- form.fields['owner'].initial = location_owner
-
- return form
-
- def get_initial(self):
- """ Provide initial data to create a new StockItem object
- """
-
- # Is the client attempting to copy an existing stock item?
- item_to_copy = self.request.GET.get('copy', None)
-
- if item_to_copy:
- try:
- original = StockItem.objects.get(pk=item_to_copy)
- initials = model_to_dict(original)
- self.ajax_form_title = _("Duplicate Stock Item")
- except StockItem.DoesNotExist:
- initials = super(StockItemCreate, self).get_initial().copy()
-
- else:
- initials = super(StockItemCreate, self).get_initial().copy()
-
- part = self.get_part()
-
- loc_id = self.request.GET.get('location', None)
- sup_part_id = self.request.GET.get('supplier_part', None)
-
- location = None
- supplier_part = None
-
- if part is not None:
- initials['part'] = part
- initials['location'] = part.get_default_location()
- initials['supplier_part'] = part.default_supplier
-
- # If the part has a defined expiry period, extrapolate!
- if part.default_expiry > 0:
- expiry_date = datetime.now().date() + timedelta(days=part.default_expiry)
- initials['expiry_date'] = expiry_date
-
- currency_code = common.settings.currency_code_default()
-
- # SupplierPart field has been specified
- # It must match the Part, if that has been supplied
- if sup_part_id:
- try:
- supplier_part = SupplierPart.objects.get(pk=sup_part_id)
-
- if part is None or supplier_part.part == part:
- initials['supplier_part'] = supplier_part
-
- currency_code = supplier_part.supplier.currency_code
-
- except (ValueError, SupplierPart.DoesNotExist):
- pass
-
- # Location has been specified
- if loc_id:
- try:
- location = StockLocation.objects.get(pk=loc_id)
- initials['location'] = location
- except (ValueError, StockLocation.DoesNotExist):
- pass
-
- currency = CURRENCIES.get(currency_code, None)
-
- if currency:
- initials['purchase_price'] = (None, currency)
-
- return initials
-
- def validate(self, item, form):
- """
- Extra form validation steps
- """
-
- data = form.cleaned_data
-
- part = data.get('part', None)
-
- quantity = data.get('quantity', None)
-
- owner = data.get('owner', None)
-
- if not part:
- return
-
- if not quantity:
- return
-
- try:
- quantity = Decimal(quantity)
- except (ValueError, InvalidOperation):
- form.add_error('quantity', _('Invalid quantity provided'))
- return
-
- if quantity < 0:
- form.add_error('quantity', _('Quantity cannot be negative'))
-
- # Trackable parts are treated differently
- if part.trackable:
- sn = data.get('serial_numbers', '')
- sn = str(sn).strip()
-
- if len(sn) > 0:
- try:
- serials = extract_serial_numbers(sn, quantity, part.getLatestSerialNumberInt())
- except ValidationError as e:
- serials = None
- form.add_error('serial_numbers', e.messages)
-
- if serials is not None:
- existing = part.find_conflicting_serial_numbers(serials)
-
- if len(existing) > 0:
- exists = ','.join([str(x) for x in existing])
-
- form.add_error(
- 'serial_numbers',
- _('Serial numbers already exist') + ': ' + exists
- )
-
- # Is ownership control enabled?
- stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
-
- if stock_ownership_control:
- # Check if owner is set
- if not owner and not self.request.user.is_superuser:
- form.add_error('owner', _('Owner is required (ownership control is enabled)'))
- return
-
- def save(self, form, **kwargs):
- """
- Create a new StockItem based on the provided form data.
- """
-
- data = form.cleaned_data
-
- part = data['part']
-
- quantity = data['quantity']
-
- if part.trackable:
- sn = data.get('serial_numbers', '')
- sn = str(sn).strip()
-
- # Create a single stock item for each provided serial number
- if len(sn) > 0:
- serials = extract_serial_numbers(sn, quantity, part.getLatestSerialNumberInt())
-
- for serial in serials:
- item = StockItem(
- part=part,
- quantity=1,
- serial=serial,
- supplier_part=data.get('supplier_part', None),
- location=data.get('location', None),
- batch=data.get('batch', None),
- delete_on_deplete=False,
- status=data.get('status'),
- link=data.get('link', ''),
- )
-
- item.save(user=self.request.user)
-
- # Create a single StockItem of the specified quantity
- else:
- form._post_clean()
-
- item = form.save(commit=False)
- item.user = self.request.user
- item.save(user=self.request.user)
-
- return item
-
- # Non-trackable part
- else:
-
- form._post_clean()
-
- item = form.save(commit=False)
- item.user = self.request.user
- item.save(user=self.request.user)
-
- return item
-
-
class StockLocationDelete(AjaxDeleteView):
"""
View to delete a StockLocation
From adbcd68fe84ee6ffe173413ebbb8fceb9e468f2e Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 17:25:24 +1000
Subject: [PATCH 35/47] Refactor "uninstall stock item" form to use the API
---
InvenTree/stock/api.py | 35 +++++++-------
InvenTree/stock/models.py | 2 +-
InvenTree/stock/serializers.py | 42 +++++++++++++++++
.../templates/stock/attachment_delete.html | 7 ---
InvenTree/stock/templates/stock/item.html | 29 ++----------
.../stock/templates/stock/item_base.html | 7 +--
.../templates/stock/stock_uninstall.html | 28 -----------
InvenTree/stock/urls.py | 2 -
InvenTree/stock/views.py | 17 +------
.../js/translated/model_renderers.js | 2 +-
InvenTree/templates/js/translated/stock.js | 47 ++++++++++++++++---
11 files changed, 111 insertions(+), 107 deletions(-)
delete mode 100644 InvenTree/stock/templates/stock/attachment_delete.html
delete mode 100644 InvenTree/stock/templates/stock/stock_uninstall.html
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 0c0dafbf41..78b56a43df 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -92,13 +92,8 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
return self.serializer_class(*args, **kwargs)
-class StockItemSerialize(generics.CreateAPIView):
- """
- API endpoint for serializing a stock item
- """
-
- queryset = StockItem.objects.none()
- serializer_class = StockSerializers.SerializeStockItemSerializer
+class StockItemContextMixin:
+ """ Mixin class for adding StockItem object to serializer context """
def get_serializer_context(self):
@@ -112,8 +107,16 @@ class StockItemSerialize(generics.CreateAPIView):
return context
+class StockItemSerialize(StockItemContextMixin, generics.CreateAPIView):
+ """
+ API endpoint for serializing a stock item
+ """
-class StockItemInstall(generics.CreateAPIView):
+ queryset = StockItem.objects.none()
+ serializer_class = StockSerializers.SerializeStockItemSerializer
+
+
+class StockItemInstall(StockItemContextMixin, generics.CreateAPIView):
"""
API endpoint for installing a particular stock item into this stock item.
@@ -125,17 +128,14 @@ class StockItemInstall(generics.CreateAPIView):
queryset = StockItem.objects.none()
serializer_class = StockSerializers.InstallStockItemSerializer
- def get_serializer_context(self):
- context = super().get_serializer_context()
- context['request'] = self.request
+class StockItemUninstall(StockItemContextMixin, generics.CreateAPIView):
+ """
+ API endpoint for removing (uninstalling) items from this item
+ """
- try:
- context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None))
- except:
- pass
-
- return context
+ queryset = StockItem.objects.none()
+ serializer_class = StockSerializers.UninstallStockItemSerializer
class StockAdjustView(generics.CreateAPIView):
@@ -1421,6 +1421,7 @@ stock_api_urls = [
re_path(r'^(?P\d+)/', include([
re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
+ re_path(r'^uninstall/', StockItemUninstall.as_view(), name='api-stock-item-uninstall'),
re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
])),
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 39697c1bca..040b748521 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -1142,7 +1142,7 @@ class StockItem(MPTTModel):
)
@transaction.atomic
- def uninstallIntoLocation(self, location, user, notes):
+ def uninstall_into_location(self, location, user, notes):
"""
Uninstall this stock item from another item, into a location.
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index a263700a41..6b27b87f93 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -448,6 +448,48 @@ class InstallStockItemSerializer(serializers.Serializer):
)
+class UninstallStockItemSerializer(serializers.Serializer):
+ """
+ API serializers for uninstalling an installed item from a stock item
+ """
+
+ class Meta:
+ fields = [
+ 'location',
+ 'note',
+ ]
+
+ location = serializers.PrimaryKeyRelatedField(
+ queryset=StockLocation.objects.all(),
+ many=False, required=True, allow_null=False,
+ label=_('Location'),
+ help_text=_('Destination location for uninstalled item')
+ )
+
+ note = serializers.CharField(
+ label=_('Notes'),
+ help_text=_('Add transaction note (optional)'),
+ required=False, allow_blank=True,
+ )
+
+ def save(self):
+
+ item = self.context['item']
+
+ data = self.validated_data
+ request = self.context['request']
+
+ location = data['location']
+
+ note = data.get('note', '')
+
+ item.uninstall_into_location(
+ location,
+ request.user,
+ note
+ )
+
+
class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""
Serializer for a simple tree view
diff --git a/InvenTree/stock/templates/stock/attachment_delete.html b/InvenTree/stock/templates/stock/attachment_delete.html
deleted file mode 100644
index 4ee7f03cb1..0000000000
--- a/InvenTree/stock/templates/stock/attachment_delete.html
+++ /dev/null
@@ -1,7 +0,0 @@
-{% extends "modal_delete_form.html" %}
-{% load i18n %}
-
-{% block pre_form_content %}
-{% trans "Are you sure you want to delete this attachment?" %}
-
-{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html
index 75e53d6758..66069c7a2e 100644
--- a/InvenTree/stock/templates/stock/item.html
+++ b/InvenTree/stock/templates/stock/item.html
@@ -159,9 +159,12 @@
-
+
+
+ {% include "filter_list.html" with id='installed-items' %}
+
-
+
@@ -207,28 +210,6 @@
quantity: {{ item.quantity|unlocalize }},
}
);
-
- $('#multi-item-uninstall').click(function() {
-
- var selections = $('#installed-table').bootstrapTable('getSelections');
-
- var items = [];
-
- selections.forEach(function(item) {
- items.push(item.pk);
- });
-
- launchModalForm(
- "{% url 'stock-item-uninstall' %}",
- {
- data: {
- 'items[]': items,
- },
- reload: true,
- }
- );
-
- });
onPanelLoad('notes', function() {
setupNotesField(
diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html
index dd77d26d1c..ea4480cc52 100644
--- a/InvenTree/stock/templates/stock/item_base.html
+++ b/InvenTree/stock/templates/stock/item_base.html
@@ -445,12 +445,9 @@ $('#stock-install-in').click(function() {
$('#stock-uninstall').click(function() {
- launchModalForm(
- "{% url 'stock-item-uninstall' %}",
+ uninstallStockItem(
+ {{ item.pk }},
{
- data: {
- 'items[]': [{{ item.pk }}],
- },
reload: true,
}
);
diff --git a/InvenTree/stock/templates/stock/stock_uninstall.html b/InvenTree/stock/templates/stock/stock_uninstall.html
deleted file mode 100644
index 2a8d9c7ee4..0000000000
--- a/InvenTree/stock/templates/stock/stock_uninstall.html
+++ /dev/null
@@ -1,28 +0,0 @@
-{% extends "modal_form.html" %}
-{% load i18n %}
-{% load inventree_extras %}
-
-{% block pre_form_content %}
-
-
- {% trans "The following stock items will be uninstalled" %}
-
-
-
- {% for item in stock_items %}
- -
- {% include "hover_image.html" with image=item.part.image hover=False %}
- {{ item }}
-
- {% endfor %}
-
-
-{% endblock %}
-
-{% block form_data %}
-
-{% for item in stock_items %}
-
-{% endfor %}
-
-{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py
index 11ad145d08..3a9ad6c490 100644
--- a/InvenTree/stock/urls.py
+++ b/InvenTree/stock/urls.py
@@ -43,8 +43,6 @@ stock_urls = [
# Stock location
re_path(r'^location/', include(location_urls)),
- re_path(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'),
-
re_path(r'^track/', include(stock_tracking_urls)),
# Individual stock items
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 1b9aba04c8..69f0426bba 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -5,38 +5,23 @@ Django views for interacting with Stock app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
-from django.core.exceptions import ValidationError
-from django.views.generic.edit import FormMixin
+
from django.views.generic import DetailView, ListView
-from django.forms.models import model_to_dict
-from django.forms import HiddenInput
from django.urls import reverse
from django.http import HttpResponseRedirect
-from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _
-from moneyed import CURRENCIES
-
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
from InvenTree.views import QRCodeView
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.forms import ConfirmForm
from InvenTree.helpers import str2bool
-from InvenTree.helpers import extract_serial_numbers
-from decimal import Decimal, InvalidOperation
-from datetime import datetime, timedelta
-
-from company.models import SupplierPart
-from part.models import Part
from .models import StockItem, StockLocation, StockItemTracking
import common.settings
-from common.models import InvenTreeSetting
-from users.models import Owner
from . import forms as StockForms
diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js
index d55d93e531..1d88fb90d7 100644
--- a/InvenTree/templates/js/translated/model_renderers.js
+++ b/InvenTree/templates/js/translated/model_renderers.js
@@ -81,7 +81,7 @@ function renderStockItem(name, data, parameters={}, options={}) {
var part_detail = '';
- if (render_part_detail) {
+ if (render_part_detail && data.part_detail) {
part_detail = `
${data.part_detail.full_name} - `;
}
diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js
index 94d21fe5b0..94f4d73ae1 100644
--- a/InvenTree/templates/js/translated/stock.js
+++ b/InvenTree/templates/js/translated/stock.js
@@ -57,6 +57,7 @@
stockItemFields,
stockLocationFields,
stockStatusCodes,
+ uninstallStockItem,
*/
@@ -2630,13 +2631,10 @@ function loadInstalledInTable(table, options) {
table.find('.button-uninstall').click(function() {
var pk = $(this).attr('pk');
- launchModalForm(
- '{% url "stock-item-uninstall" %}',
+ uninstallStockItem(
+ pk,
{
- data: {
- 'items[]': pk,
- },
- success: function() {
+ onSuccess: function(response) {
table.bootstrapTable('refresh');
}
}
@@ -2647,6 +2645,43 @@ function loadInstalledInTable(table, options) {
}
+/*
+ * Launch a dialog to uninstall a stock item from another stock item
+*/
+function uninstallStockItem(installed_item_id, options={}) {
+
+ constructForm(
+ `/api/stock/${installed_item_id}/uninstall/`,
+ {
+ confirm: true,
+ method: 'POST',
+ title: '{% trans "Uninstall Stock Item" %}',
+ fields: {
+ location: {
+ icon: 'fa-sitemap',
+ },
+ note: {},
+ },
+ preFormContent: function(opts) {
+ var html = '';
+
+ if (installed_item_id == null) {
+ html += `
+
+ {% trans "Select stock item to uninstall" %}
+ `;
+ }
+
+ return html;
+ },
+ onSuccess: function(response) {
+ handleFormSuccess(response, options);
+ }
+ }
+ );
+}
+
+
/*
* Launch a dialog to install a stock item into another stock item
*/
From 1cefdfc2e4a831370a1859b80fdcd0bd72d21c57 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 17:31:44 +1000
Subject: [PATCH 36/47] PEP style fixes
---
InvenTree/InvenTree/metadata.py | 2 +-
InvenTree/build/models.py | 2 +-
InvenTree/build/views.py | 4 +---
InvenTree/order/api.py | 1 -
InvenTree/order/serializers.py | 3 +--
InvenTree/order/views.py | 5 ++---
InvenTree/stock/api.py | 1 +
InvenTree/stock/forms.py | 10 +---------
InvenTree/stock/views.py | 1 +
9 files changed, 9 insertions(+), 20 deletions(-)
diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py
index 8a8be64a37..c3ae8f6127 100644
--- a/InvenTree/InvenTree/metadata.py
+++ b/InvenTree/InvenTree/metadata.py
@@ -42,7 +42,7 @@ class InvenTreeMetadata(SimpleMetadata):
"""
Custom context information to pass through to the OPTIONS endpoint,
if the "context=True" is supplied to the OPTIONS requst
-
+
Serializer class can supply context data by defining a get_context_data() method (no arguments)
"""
diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index 464ef33f8d..a1517d73dd 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -486,7 +486,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
for output in self.complete_outputs:
quantity += output.quantity
-
+
return quantity
@property
diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index ff12d2f211..80d648f53a 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -9,11 +9,9 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from .models import Build
-from . import forms
-from InvenTree.views import AjaxUpdateView, AjaxDeleteView
+from InvenTree.views import AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin
-from InvenTree.helpers import str2bool
from InvenTree.status_codes import BuildStatus
diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py
index c54476a733..2dab7684de 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -337,7 +337,6 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
serializer_class = serializers.PurchaseOrderIssueSerializer
-
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
"""
API endpoint to receive stock items against a purchase order.
diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py
index 6f6654db1f..c0ab91a41f 100644
--- a/InvenTree/order/serializers.py
+++ b/InvenTree/order/serializers.py
@@ -204,7 +204,7 @@ class PurchaseOrderCancelSerializer(serializers.Serializer):
if not order.can_cancel():
raise ValidationError(_("Order cannot be cancelled"))
-
+
order.cancel_order()
@@ -238,7 +238,6 @@ class PurchaseOrderIssueSerializer(serializers.Serializer):
class Meta:
fields = []
-
def save(self):
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index 73c22aca22..81a96ba37e 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -30,9 +30,8 @@ from common.files import FileManager
from . import forms as order_forms
from part.views import PartPricing
-from InvenTree.views import AjaxView, AjaxUpdateView
-from InvenTree.helpers import DownloadFile, str2bool
-from InvenTree.views import InvenTreeRoleMixin
+from InvenTree.helpers import DownloadFile
+from InvenTree.views import InvenTreeRoleMixin, AjaxView
logger = logging.getLogger("inventree")
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 78b56a43df..a42b6a2869 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -107,6 +107,7 @@ class StockItemContextMixin:
return context
+
class StockItemSerialize(StockItemContextMixin, generics.CreateAPIView):
"""
API endpoint for serializing a stock item
diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index 8e0017b3fd..7d419ab478 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -5,17 +5,9 @@ Django Forms for interacting with Stock app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
-from django import forms
-from django.forms.utils import ErrorDict
-from django.utils.translation import gettext_lazy as _
-
-from mptt.fields import TreeNodeChoiceField
-
from InvenTree.forms import HelperForm
-from InvenTree.fields import RoundingDecimalFormField
-from InvenTree.fields import DatePickerFormField
-from .models import StockLocation, StockItem, StockItemTracking
+from .models import StockItem, StockItemTracking
class ReturnStockItemForm(HelperForm):
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 69f0426bba..01d2b67c73 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -5,6 +5,7 @@ Django views for interacting with Stock app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
+from datetime import datetime
from django.views.generic import DetailView, ListView
from django.urls import reverse
From 7365f7b5cb62a470709e796e5397d7b815788899 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 17:33:19 +1000
Subject: [PATCH 37/47] Paint the icon red
---
InvenTree/build/templates/build/build_base.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html
index 558c3dc3f2..ba8cfbcd1a 100644
--- a/InvenTree/build/templates/build/build_base.html
+++ b/InvenTree/build/templates/build/build_base.html
@@ -56,7 +56,7 @@ src="{% static 'img/blank_image.png' %}"
{% trans "Cancel Build" %}
{% endif %}
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
- {% trans "Delete Build" %}
+ {% trans "Delete Build" %}
{% endif %}
From 17c84141b11e6192419858c085f98bbee7a9ea49 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 17:38:01 +1000
Subject: [PATCH 38/47] Javascript linting
---
InvenTree/templates/js/translated/forms.js | 2 +-
InvenTree/templates/js/translated/order.js | 5 ++---
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index df5c864b50..642523a60b 100644
--- a/InvenTree/templates/js/translated/forms.js
+++ b/InvenTree/templates/js/translated/forms.js
@@ -124,7 +124,7 @@ function getApiEndpointOptions(url, callback) {
}
// Include extra context information in the request
- url += '?context=true'
+ url += '?context=true';
// Return the ajax request object
$.ajax({
diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js
index 6b163afa1e..538f37a710 100644
--- a/InvenTree/templates/js/translated/order.js
+++ b/InvenTree/templates/js/translated/order.js
@@ -28,7 +28,7 @@
createSalesOrderShipment,
editPurchaseOrderLineItem,
exportOrder,
- issurPurchaseOrder,
+ issuePurchaseOrder,
loadPurchaseOrderLineItemTable,
loadPurchaseOrderExtraLineTable
loadPurchaseOrderTable,
@@ -242,8 +242,7 @@ function issuePurchaseOrder(order_id, options={}) {
handleFormSuccess(response, options);
}
}
- )
-
+ );
}
From 104f9d4a70740e2ad024f5c833041809f56a2ae2 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 18:39:33 +1000
Subject: [PATCH 39/47] Remove outdated unit tests
---
InvenTree/build/test_api.py | 81 +++++++++++++++++++++++++
InvenTree/build/tests.py | 107 ----------------------------------
InvenTree/order/test_views.py | 27 ---------
3 files changed, 81 insertions(+), 134 deletions(-)
diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py
index 9c20dea580..9551e3d07e 100644
--- a/InvenTree/build/test_api.py
+++ b/InvenTree/build/test_api.py
@@ -5,6 +5,9 @@ from datetime import datetime, timedelta
from django.urls import reverse
+from rest_framework.test import APITestCase
+from rest_framework import status
+
from part.models import Part
from build.models import Build, BuildItem
from stock.models import StockItem
@@ -13,6 +16,84 @@ from InvenTree.status_codes import BuildStatus
from InvenTree.api_tester import InvenTreeAPITestCase
+class TestBuildAPI(APITestCase):
+ """
+ Series of tests for the Build DRF API
+ - Tests for Build API
+ - Tests for BuildItem API
+ """
+
+ fixtures = [
+ 'category',
+ 'part',
+ 'location',
+ 'build',
+ ]
+
+ def setUp(self):
+ # Create a user for auth
+ user = get_user_model()
+ self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
+
+ g = Group.objects.create(name='builders')
+ self.user.groups.add(g)
+
+ for rule in g.rule_sets.all():
+ if rule.name == 'build':
+ rule.can_change = True
+ rule.can_add = True
+ rule.can_delete = True
+
+ rule.save()
+
+ g.save()
+
+ self.client.login(username='testuser', password='password')
+
+ def test_get_build_list(self):
+ """
+ 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), 1)
+
+ # 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')
+
+ response = self.client.get(url, format='json')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # Test again, filtering by park ID
+ response = self.client.get(url, {'part': '1'}, format='json')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+
class BuildAPITest(InvenTreeAPITestCase):
"""
Series of tests for the Build DRF API
diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py
index 27b7720973..dd1fe9b45f 100644
--- a/InvenTree/build/tests.py
+++ b/InvenTree/build/tests.py
@@ -6,10 +6,6 @@ from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
-from rest_framework.test import APITestCase
-from rest_framework import status
-
-import json
from datetime import datetime, timedelta
from .models import Build
@@ -112,84 +108,6 @@ class BuildTestSimple(TestCase):
self.assertEqual(build.status, BuildStatus.CANCELLED)
-class TestBuildAPI(APITestCase):
- """
- Series of tests for the Build DRF API
- - Tests for Build API
- - Tests for BuildItem API
- """
-
- fixtures = [
- 'category',
- 'part',
- 'location',
- 'build',
- ]
-
- def setUp(self):
- # Create a user for auth
- user = get_user_model()
- self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
-
- g = Group.objects.create(name='builders')
- self.user.groups.add(g)
-
- for rule in g.rule_sets.all():
- if rule.name == 'build':
- rule.can_change = True
- rule.can_add = True
- rule.can_delete = True
-
- rule.save()
-
- g.save()
-
- self.client.login(username='testuser', password='password')
-
- def test_get_build_list(self):
- """
- 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), 1)
-
- # 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')
-
- response = self.client.get(url, format='json')
- self.assertEqual(response.status_code, status.HTTP_200_OK)
-
- # Test again, filtering by park ID
- response = self.client.get(url, {'part': '1'}, format='json')
- self.assertEqual(response.status_code, status.HTTP_200_OK)
-
-
class TestBuildViews(TestCase):
""" Tests for Build app views """
@@ -251,28 +169,3 @@ class TestBuildViews(TestCase):
content = str(response.content)
self.assertIn(build.title, content)
-
- def test_build_cancel(self):
- """ Test the build cancellation form """
-
- url = reverse('build-cancel', args=(1,))
-
- # Test without confirmation
- response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
- self.assertEqual(response.status_code, 200)
-
- data = json.loads(response.content)
- self.assertFalse(data['form_valid'])
-
- b = Build.objects.get(pk=1)
- self.assertEqual(b.status, 10) # Build status is still PENDING
-
- # Test with confirmation
- response = self.client.post(url, {'confirm_cancel': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
- self.assertEqual(response.status_code, 200)
-
- data = json.loads(response.content)
- self.assertTrue(data['form_valid'])
-
- b = Build.objects.get(pk=1)
- self.assertEqual(b.status, 30) # Build status is now CANCELLED
diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py
index 220c1688db..3af234e36a 100644
--- a/InvenTree/order/test_views.py
+++ b/InvenTree/order/test_views.py
@@ -76,30 +76,3 @@ class POTests(OrderViewTestCase):
# Response should be streaming-content (file download)
self.assertIn('streaming_content', dir(response))
-
- def test_po_issue(self):
- """ Test PurchaseOrderIssue view """
-
- url = reverse('po-issue', args=(1,))
-
- order = PurchaseOrder.objects.get(pk=1)
- self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
-
- # Test without confirmation
- response = self.client.post(url, {'confirm': 0}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
- self.assertEqual(response.status_code, 200)
-
- data = json.loads(response.content)
-
- self.assertFalse(data['form_valid'])
-
- # Test WITH confirmation
- response = self.client.post(url, {'confirm': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
- self.assertEqual(response.status_code, 200)
-
- data = json.loads(response.content)
- self.assertTrue(data['form_valid'])
-
- # Test that the order was actually placed
- order = PurchaseOrder.objects.get(pk=1)
- self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
From 88dbd5aa74c1448c79051b308ea29f8f62073696 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 18:46:07 +1000
Subject: [PATCH 40/47] PEP fixes
---
InvenTree/build/test_api.py | 3 +++
InvenTree/build/tests.py | 1 +
InvenTree/order/test_views.py | 6 ------
3 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py
index 9551e3d07e..8c9d0fe7e1 100644
--- a/InvenTree/build/test_api.py
+++ b/InvenTree/build/test_api.py
@@ -5,6 +5,9 @@ from datetime import datetime, timedelta
from django.urls import reverse
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
+
from rest_framework.test import APITestCase
from rest_framework import status
diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py
index dd1fe9b45f..e8ec8b67ca 100644
--- a/InvenTree/build/tests.py
+++ b/InvenTree/build/tests.py
@@ -3,6 +3,7 @@ from __future__ import unicode_literals
from django.test import TestCase
from django.urls import reverse
+
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py
index 3af234e36a..f636d91fb9 100644
--- a/InvenTree/order/test_views.py
+++ b/InvenTree/order/test_views.py
@@ -8,12 +8,6 @@ from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
-from InvenTree.status_codes import PurchaseOrderStatus
-
-from .models import PurchaseOrder
-
-import json
-
class OrderViewTestCase(TestCase):
From 00dffd953be5913f3cfaf3c5fe3ae181e4bbb5c8 Mon Sep 17 00:00:00 2001
From: Matthias
Date: Wed, 4 May 2022 12:39:12 +0200
Subject: [PATCH 41/47] add messages if company was deleted
---
InvenTree/company/templates/company/manufacturer_part.html | 3 +++
InvenTree/company/templates/company/supplier_part.html | 3 +++
InvenTree/order/templates/order/order_base.html | 3 +++
InvenTree/report/templates/report/inventree_po_report.html | 2 +-
4 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/InvenTree/company/templates/company/manufacturer_part.html b/InvenTree/company/templates/company/manufacturer_part.html
index 881e5870ca..e3bcb3dd7e 100644
--- a/InvenTree/company/templates/company/manufacturer_part.html
+++ b/InvenTree/company/templates/company/manufacturer_part.html
@@ -91,6 +91,9 @@ src="{% static 'img/blank_image.png' %}"
{% if part.manufacturer %}
{{ part.manufacturer.name }}{% include "clip.html"%}
+ {% else %}
+ {% trans "No manufacturer information available" %}
+ {% endif %}
{% endif %}
Date: Wed, 4 May 2022 12:39:56 +0200
Subject: [PATCH 42/47] redirect to index if company was deleted
---
InvenTree/company/templates/company/manufacturer_part.html | 2 ++
1 file changed, 2 insertions(+)
diff --git a/InvenTree/company/templates/company/manufacturer_part.html b/InvenTree/company/templates/company/manufacturer_part.html
index e3bcb3dd7e..5a0e741c1a 100644
--- a/InvenTree/company/templates/company/manufacturer_part.html
+++ b/InvenTree/company/templates/company/manufacturer_part.html
@@ -338,6 +338,8 @@ $('#delete-part').click(function() {
onSuccess: function() {
{% if part.manufacturer %}
window.location.href = "{% url 'company-detail' part.manufacturer.id %}";
+ {% else%}
+ window.location.href = "{% url 'index' %}";
{% endif %}
}
});
From 19d3b03280c2064bf888a13db783a96d91acda6d Mon Sep 17 00:00:00 2001
From: Matthias
Date: Wed, 4 May 2022 13:10:49 +0200
Subject: [PATCH 43/47] fix double endif
---
InvenTree/order/templates/order/order_base.html | 1 -
1 file changed, 1 deletion(-)
diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html
index 5302b57e09..b80275b1f3 100644
--- a/InvenTree/order/templates/order/order_base.html
+++ b/InvenTree/order/templates/order/order_base.html
@@ -116,7 +116,6 @@ src="{% static 'img/blank_image.png' %}"
{% else %}
{% trans "No suppplier information available" %}
{% endif %}
- {% endif %}
{% if order.supplier_reference %}
From 7a5be35f106576521d201260bb1da0f31d23a9de Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 21:44:38 +1000
Subject: [PATCH 44/47] Add unit tests for new purchase order API endpoints:
- PurchaseOrderCancel
- PurchaseOrderComplete
- PurchaseOrderIssue
---
InvenTree/order/test_api.py | 92 +++++++++++++++++++++++++++++++++++++
1 file changed, 92 insertions(+)
diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py
index d3e405e5fa..8b3cf87b76 100644
--- a/InvenTree/order/test_api.py
+++ b/InvenTree/order/test_api.py
@@ -238,6 +238,98 @@ class PurchaseOrderTest(OrderTest):
},
expected_code=201
)
+
+ def test_po_cancel(self):
+ """
+ Test the PurchaseOrderCancel API endpoint
+ """
+
+ po = models.PurchaseOrder.objects.get(pk=1)
+
+ self.assertEqual(po.status, PurchaseOrderStatus.PENDING)
+
+ url = reverse('api-po-cancel', kwargs={'pk': po.pk})
+
+ # Try to cancel the PO, but without reqiured permissions
+ self.post(
+ url,
+ {},
+ expected_code=403,
+ )
+
+ self.assignRole('purchase_order.add')
+
+ self.post(
+ url,
+ {},
+ expected_code=201,
+ )
+
+ po.refresh_from_db()
+
+ self.assertEqual(po.status, PurchaseOrderStatus.CANCELLED)
+
+ # Try to cancel again (should fail)
+ self.post(
+ url,
+ {},
+ expected_code=400,
+ )
+
+ def test_po_complete(self):
+ """ Test the PurchaseOrderComplete API endpoint """
+
+ po = models.PurchaseOrder.objects.get(pk=3)
+
+ url = reverse('api-po-complete', kwargs={'pk': po.pk})
+
+ self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
+
+ # Try to complete the PO, without required permissions
+ response = self.post(
+ url,
+ {},
+ expected_code=403,
+ )
+
+ self.assignRole('purchase_order.add')
+
+ response = self.post(
+ url,
+ {},
+ expected_code=201,
+ )
+
+ po.refresh_from_db()
+
+ self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
+
+
+ def test_po_issue(self):
+ """ Test the PurchaseOrderIssue API endpoint """
+
+ po = models.PurchaseOrder.objects.get(pk=2)
+
+ url = reverse('api-po-issue', kwargs={'pk': po.pk})
+
+ # Try to issue the PO, without required permissions
+ response = self.post(
+ url,
+ {},
+ expected_code=403,
+ )
+
+ self.assignRole('purchase_order.add')
+
+ response = self.post(
+ url,
+ {},
+ expected_code=201,
+ )
+
+ po.refresh_from_db()
+
+ self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
class PurchaseOrderReceiveTest(OrderTest):
From 1c0fba0fca2cec8c55fc82ba0fe8617867ccc786 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 21:51:09 +1000
Subject: [PATCH 45/47] Add unit test for SalesOrderCancel API endpoint
---
InvenTree/order/test_api.py | 61 +++++++++++++++++--------------------
1 file changed, 28 insertions(+), 33 deletions(-)
diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py
index 8b3cf87b76..2ac7689434 100644
--- a/InvenTree/order/test_api.py
+++ b/InvenTree/order/test_api.py
@@ -9,7 +9,7 @@ from rest_framework import status
from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase
-from InvenTree.status_codes import PurchaseOrderStatus
+from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
from part.models import Part
from stock.models import StockItem
@@ -238,7 +238,7 @@ class PurchaseOrderTest(OrderTest):
},
expected_code=201
)
-
+
def test_po_cancel(self):
"""
Test the PurchaseOrderCancel API endpoint
@@ -251,11 +251,7 @@ class PurchaseOrderTest(OrderTest):
url = reverse('api-po-cancel', kwargs={'pk': po.pk})
# Try to cancel the PO, but without reqiured permissions
- self.post(
- url,
- {},
- expected_code=403,
- )
+ self.post(url, {}, expected_code=403)
self.assignRole('purchase_order.add')
@@ -270,11 +266,7 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(po.status, PurchaseOrderStatus.CANCELLED)
# Try to cancel again (should fail)
- self.post(
- url,
- {},
- expected_code=400,
- )
+ self.post(url, {}, expected_code=400)
def test_po_complete(self):
""" Test the PurchaseOrderComplete API endpoint """
@@ -286,25 +278,16 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
# Try to complete the PO, without required permissions
- response = self.post(
- url,
- {},
- expected_code=403,
- )
+ self.post(url, {}, expected_code=403)
self.assignRole('purchase_order.add')
- response = self.post(
- url,
- {},
- expected_code=201,
- )
+ self.post(url, {}, expected_code=201)
po.refresh_from_db()
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
-
def test_po_issue(self):
""" Test the PurchaseOrderIssue API endpoint """
@@ -313,19 +296,11 @@ class PurchaseOrderTest(OrderTest):
url = reverse('api-po-issue', kwargs={'pk': po.pk})
# Try to issue the PO, without required permissions
- response = self.post(
- url,
- {},
- expected_code=403,
- )
+ self.post(url, {}, expected_code=403)
self.assignRole('purchase_order.add')
- response = self.post(
- url,
- {},
- expected_code=201,
- )
+ self.post(url, {}, expected_code=201)
po.refresh_from_db()
@@ -880,6 +855,26 @@ class SalesOrderTest(OrderTest):
expected_code=201
)
+ def test_so_cancel(self):
+ """ Test API endpoint for cancelling a SalesOrder """
+
+ so = models.SalesOrder.objects.get(pk=1)
+
+ self.assertEqual(so.status, SalesOrderStatus.PENDING)
+
+ url = reverse('api-so-cancel', kwargs={'pk': so.pk})
+
+ # Try to cancel, without permission
+ self.post(url, {}, expected_code=403)
+
+ self.assignRole('sales_order.add')
+
+ self.post(url, {}, expected_code=201)
+
+ so.refresh_from_db()
+
+ self.assertEqual(so.status, SalesOrderStatus.CANCELLED)
+
class SalesOrderAllocateTest(OrderTest):
"""
From 055b9c9a463ab2baf4099d234da4d8a95bcdbc08 Mon Sep 17 00:00:00 2001
From: Matthias
Date: Wed, 4 May 2022 13:57:35 +0200
Subject: [PATCH 46/47] remove duplicate endif
---
InvenTree/company/templates/company/supplier_part.html | 1 -
1 file changed, 1 deletion(-)
diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html
index 930d8260e1..f990b66898 100644
--- a/InvenTree/company/templates/company/supplier_part.html
+++ b/InvenTree/company/templates/company/supplier_part.html
@@ -90,7 +90,6 @@ src="{% static 'img/blank_image.png' %}"
{% else %}
{% trans "No supplier information available" %}
{% endif %}
- {% endif %}
From 82541ede32efa2778c48e9bb7e3b3456021b9df7 Mon Sep 17 00:00:00 2001
From: Oliver Walters
Date: Wed, 4 May 2022 22:49:21 +1000
Subject: [PATCH 47/47] More unit tests
- BuildOrderCancel
- StockItemInstall
- StockItemUninstall
---
InvenTree/build/test_api.py | 17 ++++++-
InvenTree/part/models.py | 15 ++----
InvenTree/part/test_bom_item.py | 2 +-
InvenTree/stock/test_api.py | 83 +++++++++++++++++++++++++++++++++
4 files changed, 105 insertions(+), 12 deletions(-)
diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py
index 8c9d0fe7e1..a54a92dda8 100644
--- a/InvenTree/build/test_api.py
+++ b/InvenTree/build/test_api.py
@@ -122,7 +122,7 @@ class BuildAPITest(InvenTreeAPITestCase):
super().setUp()
-class BuildOutputCompleteTest(BuildAPITest):
+class BuildTest(BuildAPITest):
"""
Unit testing for the build complete API endpoint
"""
@@ -290,6 +290,21 @@ class BuildOutputCompleteTest(BuildAPITest):
# Build should have been marked as complete
self.assertTrue(self.build.is_complete)
+ def test_cancel(self):
+ """ Test that we can cancel a BuildOrder via the API """
+
+ bo = Build.objects.get(pk=1)
+
+ url = reverse('api-build-cancel', kwargs={'pk': bo.pk})
+
+ self.assertEqual(bo.status, BuildStatus.PENDING)
+
+ self.post(url, {}, expected_code=201)
+
+ bo.refresh_from_db()
+
+ self.assertEqual(bo.status, BuildStatus.CANCELLED)
+
class BuildAllocationTest(BuildAPITest):
"""
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 46b2154c43..de2616b772 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -491,7 +491,7 @@ class Part(MPTTModel):
def __str__(self):
return f"{self.full_name} - {self.description}"
- def get_parts_in_bom(self):
+ def get_parts_in_bom(self, **kwargs):
"""
Return a list of all parts in the BOM for this part.
Takes into account substitutes, variant parts, and inherited BOM items
@@ -499,27 +499,22 @@ class Part(MPTTModel):
parts = set()
- for bom_item in self.get_bom_items():
+ for bom_item in self.get_bom_items(**kwargs):
for part in bom_item.get_valid_parts_for_allocation():
parts.add(part)
return parts
- def check_if_part_in_bom(self, other_part):
+ def check_if_part_in_bom(self, other_part, **kwargs):
"""
- Check if the other_part is in the BOM for this part.
+ Check if the other_part is in the BOM for *this* part.
Note:
- Accounts for substitute parts
- Accounts for variant BOMs
"""
- for bom_item in self.get_bom_items():
- if other_part in bom_item.get_valid_parts_for_allocation():
- return True
-
- # No matches found
- return False
+ return other_part in self.get_parts_in_bom(**kwargs)
def check_add_to_bom(self, parent, raise_error=False, recursive=True):
"""
diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py
index 88548f3cf7..0789ed08c3 100644
--- a/InvenTree/part/test_bom_item.py
+++ b/InvenTree/part/test_bom_item.py
@@ -43,7 +43,7 @@ class BomItemTest(TestCase):
self.assertIn(self.orphan, parts)
- # TODO: Tests for multi-level BOMs
+ self.assertTrue(self.bob.check_if_part_in_bom(self.orphan))
def test_used_in(self):
self.assertEqual(self.bob.used_in_count, 1)
diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py
index 7f94c6dedf..1f040b008d 100644
--- a/InvenTree/stock/test_api.py
+++ b/InvenTree/stock/test_api.py
@@ -29,6 +29,7 @@ class StockAPITestCase(InvenTreeAPITestCase):
fixtures = [
'category',
'part',
+ 'bom',
'company',
'location',
'supplier_part',
@@ -643,6 +644,88 @@ class StockItemTest(StockAPITestCase):
data = self.get(url).data
self.assertEqual(data['purchase_price_currency'], 'NZD')
+ def test_install(self):
+ """ Test that stock item can be installed into antoher item, via the API """
+
+ # Select the "parent" stock item
+ parent_part = part.models.Part.objects.get(pk=100)
+
+ item = StockItem.objects.create(
+ part=parent_part,
+ serial='12345688-1230',
+ quantity=1,
+ )
+
+ sub_part = part.models.Part.objects.get(pk=50)
+ sub_item = StockItem.objects.create(
+ part=sub_part,
+ serial='xyz-123',
+ quantity=1,
+ )
+
+ n_entries = sub_item.tracking_info.count()
+
+ self.assertIsNone(sub_item.belongs_to)
+
+ url = reverse('api-stock-item-install', kwargs={'pk': item.pk})
+
+ # Try to install an item that is *not* in the BOM for this part!
+ response = self.post(
+ url,
+ {
+ 'stock_item': 520,
+ 'note': 'This should fail, as Item #522 is not in the BOM',
+ },
+ expected_code=400
+ )
+
+ self.assertIn('Selected part is not in the Bill of Materials', str(response.data))
+
+ # Now, try to install an item which *is* in the BOM for the parent part
+ response = self.post(
+ url,
+ {
+ 'stock_item': sub_item.pk,
+ 'note': "This time, it should be good!",
+ },
+ expected_code=201,
+ )
+
+ sub_item.refresh_from_db()
+
+ self.assertEqual(sub_item.belongs_to, item)
+
+ self.assertEqual(n_entries + 1, sub_item.tracking_info.count())
+
+ # Try to install again - this time, should fail because the StockItem is not available!
+ response = self.post(
+ url,
+ {
+ 'stock_item': sub_item.pk,
+ 'note': 'Expectation: failure!',
+ },
+ expected_code=400,
+ )
+
+ self.assertIn('Stock item is unavailable', str(response.data))
+
+ # Now, try to uninstall via the API
+
+ url = reverse('api-stock-item-uninstall', kwargs={'pk': sub_item.pk})
+
+ self.post(
+ url,
+ {
+ 'location': 1,
+ },
+ expected_code=201,
+ )
+
+ sub_item.refresh_from_db()
+
+ self.assertIsNone(sub_item.belongs_to)
+ self.assertEqual(sub_item.location.pk, 1)
+
class StocktakeTest(StockAPITestCase):
"""