diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 1ef1df6a8fb3..e7b9d4d95a73 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -4,11 +4,15 @@ # 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 diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py index e0f8a233228a..c3ae8f612745 100644 --- a/InvenTree/InvenTree/metadata.py +++ b/InvenTree/InvenTree/metadata.py @@ -9,6 +9,8 @@ from rest_framework.utils import model_meta from rest_framework.fields import empty +from InvenTree.helpers import str2bool + import users.models @@ -37,6 +39,22 @@ def determine_metadata(self, request, view): 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 context data by defining a get_context_data() method (no arguments) + """ + + context = {} + + if str2bool(request.query_params.get('context', False)): + + if hasattr(self.serializer, 'get_context_data'): + context = self.serializer.get_context_data() + + metadata['context'] = context + user = request.user if user is None: @@ -99,6 +117,8 @@ def get_serializer_info(self, serializer): 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/build/api.py b/InvenTree/build/api.py index 913a500d4167..e32b404ae2fd 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -233,14 +233,8 @@ def get_serializer_context(self): return ctx -class BuildOutputCreate(generics.CreateAPIView): - """ - API endpoint for creating new build output(s) - """ - - queryset = Build.objects.none() - - serializer_class = build.serializers.BuildOutputCreateSerializer +class BuildOrderContextMixin: + """ Mixin class which adds build order as serializer context variable """ def get_serializer_context(self): ctx = super().get_serializer_context() @@ -256,30 +250,27 @@ def get_serializer_context(self): return ctx -class BuildOutputComplete(generics.CreateAPIView): +class BuildOutputCreate(BuildOrderContextMixin, generics.CreateAPIView): """ - API endpoint for completing build outputs + API endpoint for creating new build output(s) """ queryset = Build.objects.none() - serializer_class = build.serializers.BuildOutputCompleteSerializer + serializer_class = build.serializers.BuildOutputCreateSerializer - def get_serializer_context(self): - ctx = super().get_serializer_context() - ctx['request'] = self.request - ctx['to_complete'] = True +class BuildOutputComplete(BuildOrderContextMixin, generics.CreateAPIView): + """ + API endpoint for completing build outputs + """ - try: - ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) - except: - pass + queryset = Build.objects.none() - return ctx + serializer_class = build.serializers.BuildOutputCompleteSerializer -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,21 +321,12 @@ 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 +class BuildCancel(BuildOrderContextMixin, generics.CreateAPIView): + """ API endpoint for cancelling a BuildOrder """ - return context + queryset = Build.objects.all() + serializer_class = build.serializers.BuildCancelSerializer class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView): @@ -527,6 +469,7 @@ class BuildAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix 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 08714b0f3c1e..77a42571d8e0 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -5,22 +5,3 @@ # -*- 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 43bca0e2386b..a1517d73ddb0 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -479,6 +479,16 @@ def complete_outputs(self): 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 @@ def complete_build(self, user): 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 @@ def cancelBuild(self, user): - 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 @@ def is_fully_allocated(self, output): # 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 bed4b5920394..0f1703750c47 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -438,6 +438,52 @@ def save(self): ) +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 4d2c77278ce3..ba8cfbcd1a71 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -56,7 +56,7 @@
  • {% trans "Cancel Build" %}
  • {% endif %} {% if build.status == BuildStatus.CANCELLED and roles.build.delete %} -
  • {% trans "Delete Build" %} +
  • {% trans "Delete Build" %} {% endif %} @@ -214,11 +214,13 @@

    }); $("#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.' %} -
    - -{% 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 9abd058996c8..5593918a3804 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -224,9 +224,13 @@ }); $("#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 2f0fe3beb1ce..000000000000 --- 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/test_api.py b/InvenTree/order/test_api.py index d3e405e5fafe..2ac76894340c 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -9,7 +9,7 @@ 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 @@ -239,6 +239,73 @@ def test_po_create(self): 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 + 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.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 + 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.PLACED) + class PurchaseOrderReceiveTest(OrderTest): """ @@ -788,6 +855,26 @@ def test_so_create(self): 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): """ diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py index 220c1688db8f..f636d91fb9c4 100644 --- a/InvenTree/order/test_views.py +++ b/InvenTree/order/test_views.py @@ -8,12 +8,6 @@ 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): @@ -76,30 +70,3 @@ def test_po_export(self): # 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) diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index f82a58182822..15e7f5b1bbc4 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -11,10 +11,6 @@ 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'), - re_path(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'), re_path(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'), @@ -33,7 +29,6 @@ ] 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 68b45ebe863b..81a96ba37e71 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -30,9 +30,8 @@ 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") @@ -87,123 +86,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 """ - - 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 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 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/part/forms.py b/InvenTree/part/forms.py index 2ba60d111bef..87210901d279 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -95,24 +95,6 @@ class Meta: ] -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/models.py b/InvenTree/part/models.py index 46b2154c43fe..de2616b772c8 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -491,7 +491,7 @@ def save(self, *args, **kwargs): 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 @@ def get_parts_in_bom(self): 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 88548f3cf721..0789ed08c32f 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -43,7 +43,7 @@ def test_in_bom(self): 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/part/views.py b/InvenTree/part/views.py index 41e734ce2a38..efaf83ae9500 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -1001,45 +1001,6 @@ def get_context_data(self, **kwargs): 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/api.py b/InvenTree/stock/api.py index 0c0dafbf41ae..a42b6a28698a 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -92,13 +92,8 @@ def get_serializer(self, *args, **kwargs): 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): @@ -113,7 +108,16 @@ def get_serializer_context(self): return context -class StockItemInstall(generics.CreateAPIView): +class StockItemSerialize(StockItemContextMixin, generics.CreateAPIView): + """ + API endpoint for serializing a stock item + """ + + 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 +129,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 - - try: - context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None)) - except: - pass +class StockItemUninstall(StockItemContextMixin, generics.CreateAPIView): + """ + API endpoint for removing (uninstalling) items from this item + """ - return context + queryset = StockItem.objects.none() + serializer_class = StockSerializers.UninstallStockItemSerializer class StockAdjustView(generics.CreateAPIView): @@ -1421,6 +1422,7 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView): 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/forms.py b/InvenTree/stock/forms.py index 3860936ae7aa..7d419ab47805 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -5,17 +5,9 @@ # -*- 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): @@ -32,23 +24,6 @@ class Meta: ] -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 +38,6 @@ class Meta: ] -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/models.py b/InvenTree/stock/models.py index 39697c1bca3b..040b748521a2 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1142,7 +1142,7 @@ def installStockItem(self, other_item, quantity, user, notes): ) @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 a263700a4188..6b27b87f939a 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -448,6 +448,48 @@ def save(self): ) +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 4ee7f03cb165..000000000000 --- 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 75e53d675827..66069c7a2e0e 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -159,9 +159,12 @@

    {% trans "Installed Stock Items" %}

    -
    +
    +
    + {% include "filter_list.html" with id='installed-items' %} +
    -
    +
    @@ -207,28 +210,6 @@

    {% trans "Installed Stock Items" %}

    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 4c8af402cf70..944e43202677 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -449,12 +449,9 @@ $('#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 2a8d9c7ee408..000000000000 --- 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/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 */