});
$("#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 48d8ca09bd5d..000000000000
--- 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_api.py b/InvenTree/build/test_api.py
index 9c20dea58005..a54a92dda837 100644
--- a/InvenTree/build/test_api.py
+++ b/InvenTree/build/test_api.py
@@ -5,6 +5,12 @@
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
+
from part.models import Part
from build.models import Build, BuildItem
from stock.models import StockItem
@@ -13,6 +19,84 @@
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
@@ -38,7 +122,7 @@ def setUp(self):
super().setUp()
-class BuildOutputCompleteTest(BuildAPITest):
+class BuildTest(BuildAPITest):
"""
Unit testing for the build complete API endpoint
"""
@@ -206,6 +290,21 @@ def test_complete(self):
# 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/build/test_build.py b/InvenTree/build/test_build.py
index 914cef29ab47..e2cda00ec956 100644
--- a/InvenTree/build/test_build.py
+++ b/InvenTree/build/test_build.py
@@ -304,7 +304,7 @@ def test_cancel(self):
"""
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 7afd078ce93c..e8ec8b67ca2e 100644
--- a/InvenTree/build/tests.py
+++ b/InvenTree/build/tests.py
@@ -3,13 +3,10 @@
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
-from rest_framework.test import APITestCase
-from rest_framework import status
-
-import json
from datetime import datetime, timedelta
from .models import Build
@@ -107,89 +104,11 @@ def test_cancel_build(self):
self.assertEqual(build.status, BuildStatus.PENDING)
- build.cancelBuild(self.user)
+ build.cancel_build(self.user)
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 +170,3 @@ def test_build_detail(self):
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/build/urls.py b/InvenTree/build/urls.py
index 520d97ef6a76..0788a1de3701 100644
--- a/InvenTree/build/urls.py
+++ b/InvenTree/build/urls.py
@@ -7,7 +7,6 @@
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 2b8629afe940..80d648f53ab3 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -9,11 +9,9 @@
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
@@ -43,37 +41,6 @@ def get_context_data(self, **kwargs):
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/order/api.py b/InvenTree/order/api.py
index a54c37012125..2dab7684deea 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -286,7 +286,58 @@ def get_queryset(self, *args, **kwargs):
return queryset
-class PurchaseOrderReceive(generics.CreateAPIView):
+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 """
+
+ 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.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 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.
@@ -303,20 +354,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):
"""
@@ -834,13 +871,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):
@@ -856,7 +888,22 @@ def get_serializer_context(self):
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.
@@ -865,22 +912,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
@@ -891,20 +924,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):
"""
@@ -1106,7 +1125,10 @@ class PurchaseOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, Attac
# 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'),
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
])),
@@ -1143,6 +1165,7 @@ class PurchaseOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, Attac
# 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 d6b99b7fd993..a08cf81ab100 100644
--- a/InvenTree/order/forms.py
+++ b/InvenTree/order/forms.py
@@ -8,60 +8,12 @@
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 IssuePurchaseOrderForm(HelperForm):
-
- confirm = forms.BooleanField(required=True, initial=False, label=_('Confirm'), help_text=_('Place order'))
-
- class Meta:
- model = PurchaseOrder
- fields = [
- 'confirm',
- ]
-
-
-class CompletePurchaseOrderForm(HelperForm):
-
- confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_("Mark order as complete"))
-
- class Meta:
- model = PurchaseOrder
- fields = [
- 'confirm',
- ]
-
-
-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'))
-
- class Meta:
- model = SalesOrder
- fields = [
- 'confirm',
- ]
-
class OrderMatchItemForm(MatchItemForm):
""" Override MatchItemForm fields """
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
index 060d638de133..6ca5b7a293c4 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -381,6 +381,7 @@ def can_cancel(self):
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 7d26ce741dbb..c0ab91a41f1c 100644
--- a/InvenTree/order/serializers.py
+++ b/InvenTree/order/serializers.py
@@ -179,6 +179,72 @@ class Meta:
]
+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 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']
+ 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):
@staticmethod
@@ -974,6 +1040,25 @@ def save(self):
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 4ee7f03cb165..000000000000
--- 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/order_base.html b/InvenTree/order/templates/order/order_base.html
index b80275b1f3a8..3657e20e6222 100644
--- a/InvenTree/order/templates/order/order_base.html
+++ b/InvenTree/order/templates/order/order_base.html
@@ -192,10 +192,14 @@
{% if order.status == PurchaseOrderStatus.PENDING %}
$("#place-order").click(function() {
- launchModalForm("{% url 'po-issue' order.id %}",
- {
- reload: true,
- });
+
+ issuePurchaseOrder(
+ {{ order.pk }},
+ {
+ reload: true,
+ }
+ );
+
});
{% endif %}
@@ -258,15 +262,27 @@
});
$("#complete-order").click(function() {
- launchModalForm("{% url 'po-complete' order.id %}", {
- reload: true,
- });
+
+ completePurchaseOrder(
+ {{ order.pk }},
+ {
+ onSuccess: function() {
+ window.location.reload();
+ }
+ }
+ );
});
$("#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 7cdb03ae20f8..000000000000
--- 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/templates/order/order_complete.html b/InvenTree/order/templates/order/order_complete.html
deleted file mode 100644
index ef35841f9d25..000000000000
--- 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/templates/order/order_issue.html b/InvenTree/order/templates/order/order_issue.html
deleted file mode 100644
index 058a7b529cdf..000000000000
--- 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.' %}
-
- {% 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/test_api.py b/InvenTree/stock/test_api.py
index 7f94c6dedf75..1f040b008d8b 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 @@ def test_purchase_price(self):
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):
"""
diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py
index 11ad145d0807..3a9ad6c49009 100644
--- a/InvenTree/stock/urls.py
+++ b/InvenTree/stock/urls.py
@@ -43,8 +43,6 @@
# 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 3bca77ec9ebd..01d2b67c73b6 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -5,39 +5,24 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
-from django.core.exceptions import ValidationError
-from django.views.generic.edit import FormMixin
+from datetime import datetime
+
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 AjaxView
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
@@ -135,139 +120,6 @@ def get(self, request, *args, **kwargs):
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 +218,6 @@ def get_qr_data(self):
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 +252,6 @@ def save(self, obj, form):
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
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index d68b319a25e8..a636cfeec845 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={}) {
diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index cc138052ef35..642523a60b37 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,
@@ -335,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.
@@ -527,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) {
diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js
index d55d93e531e2..1d88fb90d795 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/order.js b/InvenTree/templates/js/translated/order.js
index d5ca7caf4240..538f37a7107a 100644
--- a/InvenTree/templates/js/translated/order.js
+++ b/InvenTree/templates/js/translated/order.js
@@ -20,11 +20,15 @@
/* exported
allocateStockToSalesOrder,
+ cancelPurchaseOrder,
+ cancelSalesOrder,
+ completePurchaseOrder,
completeShipment,
createSalesOrder,
createSalesOrderShipment,
editPurchaseOrderLineItem,
exportOrder,
+ issuePurchaseOrder,
loadPurchaseOrderLineItemTable,
loadPurchaseOrderExtraLineTable
loadPurchaseOrderTable,
@@ -140,6 +144,133 @@ function completeShipment(shipment_id) {
});
}
+/*
+ * Launches a modal form to mark a PurchaseOrder as "complete"
+*/
+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) {
+ handleFormSuccess(response, options);
+ }
+ }
+ );
+}
+
+
+/*
+ * Launches a modal form to mark a PurchaseOrder as 'cancelled'
+ */
+function cancelPurchaseOrder(order_id, options={}) {
+
+ constructForm(
+ `/api/order/po/${order_id}/cancel/`,
+ {
+ method: 'POST',
+ title: '{% trans "Cancel Purchase Order" %}',
+ confirm: true,
+ 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);
+ }
+ }
+ );
+}
+
+
+/*
+ * 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={}) {
diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js
index 94d21fe5b0e4..94f4d73ae19c 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
*/