Merge branch 'inventree:master' into matmair/issue2385

This commit is contained in:
Matthias Mair 2022-05-05 11:26:25 +02:00 committed by GitHub
commit 6bd997ffad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 933 additions and 1767 deletions

View File

@ -4,11 +4,15 @@ InvenTree API version information
# InvenTree API version # 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 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 v43 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2875
- Adds API detail endpoint for PartSalePrice model - Adds API detail endpoint for PartSalePrice model
- Adds API detail endpoint for PartInternalPrice model - Adds API detail endpoint for PartInternalPrice model

View File

@ -9,6 +9,8 @@ from rest_framework.metadata import SimpleMetadata
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
from rest_framework.fields import empty from rest_framework.fields import empty
from InvenTree.helpers import str2bool
import users.models import users.models
@ -37,6 +39,22 @@ class InvenTreeMetadata(SimpleMetadata):
metadata = super().determine_metadata(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 user = request.user
if user is None: if user is None:
@ -99,6 +117,8 @@ class InvenTreeMetadata(SimpleMetadata):
to any fields whose Meta.model specifies a default value to any fields whose Meta.model specifies a default value
""" """
self.serializer = serializer
serializer_info = super().get_serializer_info(serializer) serializer_info = super().get_serializer_info(serializer)
model_class = None model_class = None

View File

@ -233,7 +233,24 @@ class BuildUnallocate(generics.CreateAPIView):
return ctx return ctx
class BuildOutputCreate(generics.CreateAPIView): class BuildOrderContextMixin:
""" Mixin class which adds build order as serializer context variable """
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['request'] = self.request
ctx['to_complete'] = True
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
class BuildOutputCreate(BuildOrderContextMixin, generics.CreateAPIView):
""" """
API endpoint for creating new build output(s) API endpoint for creating new build output(s)
""" """
@ -242,21 +259,8 @@ class BuildOutputCreate(generics.CreateAPIView):
serializer_class = build.serializers.BuildOutputCreateSerializer serializer_class = build.serializers.BuildOutputCreateSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['request'] = self.request class BuildOutputComplete(BuildOrderContextMixin, generics.CreateAPIView):
ctx['to_complete'] = True
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
class BuildOutputComplete(generics.CreateAPIView):
""" """
API endpoint for completing build outputs API endpoint for completing build outputs
""" """
@ -265,21 +269,8 @@ class BuildOutputComplete(generics.CreateAPIView):
serializer_class = build.serializers.BuildOutputCompleteSerializer serializer_class = build.serializers.BuildOutputCompleteSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['request'] = self.request class BuildOutputDelete(BuildOrderContextMixin, generics.CreateAPIView):
ctx['to_complete'] = True
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
class BuildOutputDelete(generics.CreateAPIView):
""" """
API endpoint for deleting multiple build outputs API endpoint for deleting multiple build outputs
""" """
@ -288,20 +279,8 @@ class BuildOutputDelete(generics.CreateAPIView):
serializer_class = build.serializers.BuildOutputDeleteSerializer serializer_class = build.serializers.BuildOutputDeleteSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['request'] = self.request class BuildFinish(BuildOrderContextMixin, generics.CreateAPIView):
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
class BuildFinish(generics.CreateAPIView):
""" """
API endpoint for marking a build as finished (completed) API endpoint for marking a build as finished (completed)
""" """
@ -310,20 +289,8 @@ class BuildFinish(generics.CreateAPIView):
serializer_class = build.serializers.BuildCompleteSerializer serializer_class = build.serializers.BuildCompleteSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['request'] = self.request class BuildAutoAllocate(BuildOrderContextMixin, generics.CreateAPIView):
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
class BuildAutoAllocate(generics.CreateAPIView):
""" """
API endpoint for 'automatically' allocating stock against a build order. API endpoint for 'automatically' allocating stock against a build order.
@ -337,24 +304,8 @@ class BuildAutoAllocate(generics.CreateAPIView):
serializer_class = build.serializers.BuildAutoAllocationSerializer serializer_class = build.serializers.BuildAutoAllocationSerializer
def get_serializer_context(self):
"""
Provide the Build object to the serializer context
"""
context = super().get_serializer_context() class BuildAllocate(BuildOrderContextMixin, generics.CreateAPIView):
try:
context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
context['request'] = self.request
return context
class BuildAllocate(generics.CreateAPIView):
""" """
API endpoint to allocate stock items to a build order API endpoint to allocate stock items to a build order
@ -370,21 +321,12 @@ class BuildAllocate(generics.CreateAPIView):
serializer_class = build.serializers.BuildAllocationSerializer serializer_class = build.serializers.BuildAllocationSerializer
def get_serializer_context(self):
"""
Provide the Build object to the serializer context
"""
context = super().get_serializer_context() class BuildCancel(BuildOrderContextMixin, generics.CreateAPIView):
""" API endpoint for cancelling a BuildOrder """
try: queryset = Build.objects.all()
context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) serializer_class = build.serializers.BuildCancelSerializer
except:
pass
context['request'] = self.request
return context
class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView): class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView):
@ -527,6 +469,7 @@ build_api_urls = [
re_path(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'), 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'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
re_path(r'^finish/', BuildFinish.as_view(), name='api-build-finish'), 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'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
re_path(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), re_path(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
])), ])),

View File

@ -5,22 +5,3 @@ Django Forms for interacting with Build objects
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals 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'
]

View File

@ -479,6 +479,16 @@ class Build(MPTTModel, ReferenceIndexingMixin):
return outputs return outputs
@property
def complete_count(self):
quantity = 0
for output in self.complete_outputs:
quantity += output.quantity
return quantity
@property @property
def incomplete_outputs(self): def incomplete_outputs(self):
""" """
@ -588,7 +598,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
trigger_event('build.completed', id=self.pk) trigger_event('build.completed', id=self.pk)
@transaction.atomic @transaction.atomic
def cancelBuild(self, user): def cancel_build(self, user, **kwargs):
""" Mark the Build as CANCELLED """ Mark the Build as CANCELLED
- Delete any pending BuildItem objects (but do not remove items from stock) - Delete any pending BuildItem objects (but do not remove items from stock)
@ -596,8 +606,23 @@ class Build(MPTTModel, ReferenceIndexingMixin):
- Save the Build object - Save the Build object
""" """
for item in self.allocated_stock.all(): remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
item.delete() 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 # Date of 'completion' is the date the build was cancelled
self.completion_date = datetime.now().date() self.completion_date = datetime.now().date()
@ -1025,6 +1050,24 @@ class Build(MPTTModel, ReferenceIndexingMixin):
# All parts must be fully allocated! # All parts must be fully allocated!
return True 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): def are_untracked_parts_allocated(self):
""" """
Returns True if the un-tracked parts are fully allocated for this BuildOrder Returns True if the un-tracked parts are fully allocated for this BuildOrder

View File

@ -438,6 +438,52 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
) )
class BuildCancelSerializer(serializers.Serializer):
class Meta:
fields = [
'remove_allocated_stock',
'remove_incomplete_outputs',
]
def get_context_data(self):
build = self.context['build']
return {
'has_allocated_stock': build.is_partially_allocated(None),
'incomplete_outputs': build.incomplete_count,
'completed_outputs': build.complete_count,
}
remove_allocated_stock = serializers.BooleanField(
label=_('Remove Allocated Stock'),
help_text=_('Subtract any stock which has already been allocated to this build'),
required=False,
default=False,
)
remove_incomplete_outputs = serializers.BooleanField(
label=_('Remove Incomplete Outputs'),
help_text=_('Delete any build outputs which have not been completed'),
required=False,
default=False,
)
def save(self):
build = self.context['build']
request = self.context['request']
data = self.validated_data
build.cancel_build(
request.user,
remove_allocated_stock=data.get('remove_unallocated_stock', False),
remove_incomplete_outputs=data.get('remove_incomplete_outputs', False),
)
class BuildCompleteSerializer(serializers.Serializer): class BuildCompleteSerializer(serializers.Serializer):
""" """
DRF serializer for marking a BuildOrder as complete DRF serializer for marking a BuildOrder as complete

View File

@ -56,7 +56,7 @@ src="{% static 'img/blank_image.png' %}"
<li><a class='dropdown-item' href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li> <li><a class='dropdown-item' href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
{% endif %} {% endif %}
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %} {% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
<li><a class='dropdown-item' href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build" %}</a> <li><a class='dropdown-item' href='#' id='build-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Build" %}</a>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@ -214,11 +214,13 @@ src="{% static 'img/blank_image.png' %}"
}); });
$("#build-cancel").click(function() { $("#build-cancel").click(function() {
launchModalForm("{% url 'build-cancel' build.id %}",
cancelBuildOrder(
{{ build.pk }},
{ {
reload: true, reload: true,
submit_text: '{% trans "Cancel Build" %}', }
}); );
}); });
$("#build-complete").on('click', function() { $("#build-complete").on('click', function() {

View File

@ -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 %}

View File

@ -5,6 +5,12 @@ from datetime import datetime, timedelta
from django.urls import reverse 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 part.models import Part
from build.models import Build, BuildItem from build.models import Build, BuildItem
from stock.models import StockItem from stock.models import StockItem
@ -13,6 +19,84 @@ from InvenTree.status_codes import BuildStatus
from InvenTree.api_tester import InvenTreeAPITestCase 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): class BuildAPITest(InvenTreeAPITestCase):
""" """
Series of tests for the Build DRF API Series of tests for the Build DRF API
@ -38,7 +122,7 @@ class BuildAPITest(InvenTreeAPITestCase):
super().setUp() super().setUp()
class BuildOutputCompleteTest(BuildAPITest): class BuildTest(BuildAPITest):
""" """
Unit testing for the build complete API endpoint Unit testing for the build complete API endpoint
""" """
@ -206,6 +290,21 @@ class BuildOutputCompleteTest(BuildAPITest):
# Build should have been marked as complete # Build should have been marked as complete
self.assertTrue(self.build.is_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): class BuildAllocationTest(BuildAPITest):
""" """

View File

@ -304,7 +304,7 @@ class BuildTest(BuildTestBase):
""" """
self.allocate_stock(50, 50, 200, self.output_1) self.allocate_stock(50, 50, 200, self.output_1)
self.build.cancelBuild(None) self.build.cancel_build(None)
self.assertEqual(BuildItem.objects.count(), 0) self.assertEqual(BuildItem.objects.count(), 0)
""" """

View File

@ -3,13 +3,10 @@ from __future__ import unicode_literals
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group 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 datetime import datetime, timedelta
from .models import Build from .models import Build
@ -107,89 +104,11 @@ class BuildTestSimple(TestCase):
self.assertEqual(build.status, BuildStatus.PENDING) self.assertEqual(build.status, BuildStatus.PENDING)
build.cancelBuild(self.user) build.cancel_build(self.user)
self.assertEqual(build.status, BuildStatus.CANCELLED) 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): class TestBuildViews(TestCase):
""" Tests for Build app views """ """ Tests for Build app views """
@ -251,28 +170,3 @@ class TestBuildViews(TestCase):
content = str(response.content) content = str(response.content)
self.assertIn(build.title, 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

View File

@ -7,7 +7,6 @@ from django.urls import include, re_path
from . import views from . import views
build_detail_urls = [ 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'^delete/', views.BuildDelete.as_view(), name='build-delete'),
re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),

View File

@ -9,11 +9,9 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from .models import Build 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.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
@ -43,37 +41,6 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
return context 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): class BuildDetail(InvenTreeRoleMixin, DetailView):
""" """
Detail view of a single Build object. Detail view of a single Build object.

View File

@ -286,7 +286,58 @@ class PurchaseOrderDetail(generics.RetrieveUpdateDestroyAPIView):
return queryset 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. API endpoint to receive stock items against a purchase order.
@ -303,20 +354,6 @@ class PurchaseOrderReceive(generics.CreateAPIView):
serializer_class = serializers.PurchaseOrderReceiveSerializer 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): class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
""" """
@ -834,13 +871,8 @@ class SalesOrderLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = serializers.SalesOrderLineItemSerializer serializer_class = serializers.SalesOrderLineItemSerializer
class SalesOrderComplete(generics.CreateAPIView): class SalesOrderContextMixin:
""" """ Mixin to add sales order object as serializer context variable """
API endpoint for manually marking a SalesOrder as "complete".
"""
queryset = models.SalesOrder.objects.all()
serializer_class = serializers.SalesOrderCompleteSerializer
def get_serializer_context(self): def get_serializer_context(self):
@ -856,7 +888,22 @@ class SalesOrderComplete(generics.CreateAPIView):
return ctx 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, API endpoint to allocation stock items against a SalesOrder,
by specifying serial numbers. by specifying serial numbers.
@ -865,22 +912,8 @@ class SalesOrderAllocateSerials(generics.CreateAPIView):
queryset = models.SalesOrder.objects.none() queryset = models.SalesOrder.objects.none()
serializer_class = serializers.SalesOrderSerialAllocationSerializer serializer_class = serializers.SalesOrderSerialAllocationSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context() class SalesOrderAllocate(SalesOrderContextMixin, generics.CreateAPIView):
# 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):
""" """
API endpoint to allocate stock items against a SalesOrder API endpoint to allocate stock items against a SalesOrder
@ -891,20 +924,6 @@ class SalesOrderAllocate(generics.CreateAPIView):
queryset = models.SalesOrder.objects.none() queryset = models.SalesOrder.objects.none()
serializer_class = serializers.SalesOrderShipmentAllocationSerializer 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): class SalesOrderAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
""" """
@ -1106,7 +1125,10 @@ order_api_urls = [
# Individual purchase order detail URLs # Individual purchase order detail URLs
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\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'^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'), re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
])), ])),
@ -1143,6 +1165,7 @@ order_api_urls = [
# Sales order detail view # Sales order detail view
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\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'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'), re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),

View File

@ -8,60 +8,12 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from InvenTree.forms import HelperForm
from InvenTree.fields import InvenTreeMoneyField from InvenTree.fields import InvenTreeMoneyField
from InvenTree.helpers import clean_decimal from InvenTree.helpers import clean_decimal
from common.forms import MatchItemForm 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): class OrderMatchItemForm(MatchItemForm):
""" Override MatchItemForm fields """ """ Override MatchItemForm fields """

View File

@ -381,6 +381,7 @@ class PurchaseOrder(Order):
PurchaseOrderStatus.PENDING PurchaseOrderStatus.PENDING
] ]
@transaction.atomic
def cancel_order(self): def cancel_order(self):
""" Marks the PurchaseOrder as CANCELLED. """ """ Marks the PurchaseOrder as CANCELLED. """

View File

@ -179,6 +179,72 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializ
] ]
class PurchaseOrderCancelSerializer(serializers.Serializer):
"""
Serializer for cancelling a PurchaseOrder
"""
class Meta:
fields = [],
def get_context_data(self):
"""
Return custom context information about the order
"""
self.order = self.context['order']
return {
'can_cancel': self.order.can_cancel(),
}
def save(self):
order = self.context['order']
if not order.can_cancel():
raise ValidationError(_("Order cannot be cancelled"))
order.cancel_order()
class 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): class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
@staticmethod @staticmethod
@ -974,6 +1040,25 @@ class SalesOrderCompleteSerializer(serializers.Serializer):
order.complete_order(user) 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): class SalesOrderSerialAllocationSerializer(serializers.Serializer):
""" """
DRF serializer for allocation of serial numbers against a sales order / shipment DRF serializer for allocation of serial numbers against a sales order / shipment

View File

@ -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?" %}
<br>
{% endblock %}

View File

@ -192,10 +192,14 @@ src="{% static 'img/blank_image.png' %}"
{% if order.status == PurchaseOrderStatus.PENDING %} {% if order.status == PurchaseOrderStatus.PENDING %}
$("#place-order").click(function() { $("#place-order").click(function() {
launchModalForm("{% url 'po-issue' order.id %}",
issuePurchaseOrder(
{{ order.pk }},
{ {
reload: true, reload: true,
}); }
);
}); });
{% endif %} {% endif %}
@ -258,15 +262,27 @@ $("#receive-order").click(function() {
}); });
$("#complete-order").click(function() { $("#complete-order").click(function() {
launchModalForm("{% url 'po-complete' order.id %}", {
reload: true, completePurchaseOrder(
}); {{ order.pk }},
{
onSuccess: function() {
window.location.reload();
}
}
);
}); });
$("#cancel-order").click(function() { $("#cancel-order").click(function() {
launchModalForm("{% url 'po-cancel' order.id %}", {
reload: true, cancelPurchaseOrder(
}); {{ order.pk }},
{
onSuccess: function() {
window.location.reload();
}
},
);
}); });
$("#export-order").click(function() { $("#export-order").click(function() {

View File

@ -1,11 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-danger alert-block'>
{% trans "Cancelling this order means that the order and line items will no longer be editable." %}
</div>
{% endblock %}

View File

@ -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 %}
<div class='alert alert-warning alert-block' style='margin-top:12px'>
{% trans 'This order has line items which have not been marked as received.' %}</br>
{% trans 'Completing this order means that the order and line items will no longer be editable.' %}
</div>
{% endif %}
{% endblock %}

View File

@ -1,11 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-warning alert-block'>
{% trans 'After placing this purchase order, line items will no longer be editable.' %}
</div>
{% endblock %}

View File

@ -224,9 +224,13 @@ $("#edit-order").click(function() {
}); });
$("#cancel-order").click(function() { $("#cancel-order").click(function() {
launchModalForm("{% url 'so-cancel' order.id %}", {
cancelSalesOrder(
{{ order.pk }},
{
reload: true, reload: true,
}); }
);
}); });
$("#complete-order").click(function() { $("#complete-order").click(function() {

View File

@ -1,12 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-warning'>
<h4>{% trans "Warning" %}</h4>
{% trans "Cancelling this order means that the order will no longer be editable." %}
</div>
{% endblock %}

View File

@ -9,7 +9,7 @@ from rest_framework import status
from django.urls import reverse from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase 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 part.models import Part
from stock.models import StockItem from stock.models import StockItem
@ -239,6 +239,73 @@ class PurchaseOrderTest(OrderTest):
expected_code=201 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): class PurchaseOrderReceiveTest(OrderTest):
""" """
@ -788,6 +855,26 @@ class SalesOrderTest(OrderTest):
expected_code=201 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): class SalesOrderAllocateTest(OrderTest):
""" """

View File

@ -8,12 +8,6 @@ from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from InvenTree.status_codes import PurchaseOrderStatus
from .models import PurchaseOrder
import json
class OrderViewTestCase(TestCase): class OrderViewTestCase(TestCase):
@ -76,30 +70,3 @@ class POTests(OrderViewTestCase):
# Response should be streaming-content (file download) # Response should be streaming-content (file download)
self.assertIn('streaming_content', dir(response)) 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)

View File

@ -11,10 +11,6 @@ from . import views
purchase_order_detail_urls = [ 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'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'),
re_path(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'), re_path(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'),
@ -33,7 +29,6 @@ purchase_order_urls = [
] ]
sales_order_detail_urls = [ sales_order_detail_urls = [
re_path(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'),
re_path(r'^export/', views.SalesOrderExport.as_view(), name='so-export'), re_path(r'^export/', views.SalesOrderExport.as_view(), name='so-export'),
re_path(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'), re_path(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'),

View File

@ -30,9 +30,8 @@ from common.files import FileManager
from . import forms as order_forms from . import forms as order_forms
from part.views import PartPricing from part.views import PartPricing
from InvenTree.views import AjaxView, AjaxUpdateView from InvenTree.helpers import DownloadFile
from InvenTree.helpers import DownloadFile, str2bool from InvenTree.views import InvenTreeRoleMixin, AjaxView
from InvenTree.views import InvenTreeRoleMixin
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@ -87,123 +86,6 @@ class SalesOrderDetail(InvenTreeRoleMixin, DetailView):
template_name = 'order/sales_order_detail.html' 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): class PurchaseOrderUpload(FileManagementFormView):
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) ''' ''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''

View File

@ -95,24 +95,6 @@ class EditPartParameterTemplateForm(HelperForm):
] ]
class EditCategoryForm(HelperForm):
""" Form for editing a PartCategory object """
field_prefix = {
'default_keywords': 'fa-key',
}
class Meta:
model = PartCategory
fields = [
'parent',
'name',
'description',
'default_location',
'default_keywords',
]
class EditCategoryParameterTemplateForm(HelperForm): class EditCategoryParameterTemplateForm(HelperForm):
""" Form for editing a PartCategoryParameterTemplate object """ """ Form for editing a PartCategoryParameterTemplate object """

View File

@ -491,7 +491,7 @@ class Part(MPTTModel):
def __str__(self): def __str__(self):
return f"{self.full_name} - {self.description}" 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. Return a list of all parts in the BOM for this part.
Takes into account substitutes, variant parts, and inherited BOM items Takes into account substitutes, variant parts, and inherited BOM items
@ -499,27 +499,22 @@ class Part(MPTTModel):
parts = set() 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(): for part in bom_item.get_valid_parts_for_allocation():
parts.add(part) parts.add(part)
return parts 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: Note:
- Accounts for substitute parts - Accounts for substitute parts
- Accounts for variant BOMs - Accounts for variant BOMs
""" """
for bom_item in self.get_bom_items(): return other_part in self.get_parts_in_bom(**kwargs)
if other_part in bom_item.get_valid_parts_for_allocation():
return True
# No matches found
return False
def check_add_to_bom(self, parent, raise_error=False, recursive=True): def check_add_to_bom(self, parent, raise_error=False, recursive=True):
""" """

View File

@ -43,7 +43,7 @@ class BomItemTest(TestCase):
self.assertIn(self.orphan, parts) 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): def test_used_in(self):
self.assertEqual(self.bob.used_in_count, 1) self.assertEqual(self.bob.used_in_count, 1)

View File

@ -1001,45 +1001,6 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
return context 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): class CategoryDelete(AjaxDeleteView):
""" """
Delete view to delete a PartCategory Delete view to delete a PartCategory

View File

@ -92,13 +92,8 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
class StockItemSerialize(generics.CreateAPIView): class StockItemContextMixin:
""" """ Mixin class for adding StockItem object to serializer context """
API endpoint for serializing a stock item
"""
queryset = StockItem.objects.none()
serializer_class = StockSerializers.SerializeStockItemSerializer
def get_serializer_context(self): def get_serializer_context(self):
@ -113,7 +108,16 @@ class StockItemSerialize(generics.CreateAPIView):
return context 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. API endpoint for installing a particular stock item into this stock item.
@ -125,17 +129,14 @@ class StockItemInstall(generics.CreateAPIView):
queryset = StockItem.objects.none() queryset = StockItem.objects.none()
serializer_class = StockSerializers.InstallStockItemSerializer serializer_class = StockSerializers.InstallStockItemSerializer
def get_serializer_context(self):
context = super().get_serializer_context() class StockItemUninstall(StockItemContextMixin, generics.CreateAPIView):
context['request'] = self.request """
API endpoint for removing (uninstalling) items from this item
"""
try: queryset = StockItem.objects.none()
context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None)) serializer_class = StockSerializers.UninstallStockItemSerializer
except:
pass
return context
class StockAdjustView(generics.CreateAPIView): class StockAdjustView(generics.CreateAPIView):
@ -1421,6 +1422,7 @@ stock_api_urls = [
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'), 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'^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'), re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
])), ])),

View File

@ -5,17 +5,9 @@ Django Forms for interacting with Stock app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals 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.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): class ReturnStockItemForm(HelperForm):
@ -32,23 +24,6 @@ class ReturnStockItemForm(HelperForm):
] ]
class EditStockLocationForm(HelperForm):
"""
Form for editing a StockLocation
TODO: Migrate this form to the modern API forms interface
"""
class Meta:
model = StockLocation
fields = [
'name',
'parent',
'description',
'owner',
]
class ConvertStockItemForm(HelperForm): class ConvertStockItemForm(HelperForm):
""" """
Form for converting a StockItem to a variant of its current part. Form for converting a StockItem to a variant of its current part.
@ -63,159 +38,6 @@ class ConvertStockItemForm(HelperForm):
] ]
class CreateStockItemForm(HelperForm):
"""
Form for creating a new StockItem
TODO: Migrate this form to the modern API forms interface
"""
expiry_date = DatePickerFormField(
label=_('Expiry Date'),
help_text=_('Expiration date for this stock item'),
)
serial_numbers = forms.CharField(label=_('Serial Numbers'), required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
def __init__(self, *args, **kwargs):
self.field_prefix = {
'serial_numbers': 'fa-hashtag',
'link': 'fa-link',
}
super().__init__(*args, **kwargs)
class Meta:
model = StockItem
fields = [
'part',
'supplier_part',
'location',
'quantity',
'batch',
'serial_numbers',
'packaging',
'purchase_price',
'expiry_date',
'link',
'delete_on_deplete',
'status',
'owner',
]
# Custom clean to prevent complex StockItem.clean() logic from running (yet)
def full_clean(self):
self._errors = ErrorDict()
if not self.is_bound: # Stop further processing.
return
self.cleaned_data = {}
# If the form is permitted to be empty, and none of the form data has
# changed from the initial data, short circuit any validation.
if self.empty_permitted and not self.has_changed():
return
# Don't run _post_clean() as this will run StockItem.clean()
self._clean_fields()
self._clean_form()
class SerializeStockForm(HelperForm):
"""
Form for serializing a StockItem.
TODO: Migrate this form to the modern API forms interface
"""
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination for serialized stock (by default, will remain in current location)'))
serial_numbers = forms.CharField(label=_('Serial numbers'), required=True, help_text=_('Unique serial numbers (must match quantity)'))
note = forms.CharField(label=_('Notes'), required=False, help_text=_('Add transaction note (optional)'))
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
def __init__(self, *args, **kwargs):
# Extract the stock item
item = kwargs.pop('item', None)
if item:
self.field_placeholder['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
super().__init__(*args, **kwargs)
class Meta:
model = StockItem
fields = [
'quantity',
'serial_numbers',
'destination',
'note',
]
class UninstallStockForm(forms.ModelForm):
"""
Form for uninstalling a stock item which is installed in another item.
TODO: Migrate this form to the modern API forms interface
"""
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Destination location for uninstalled items'))
note = forms.CharField(label=_('Notes'), required=False, help_text=_('Add transaction note (optional)'))
confirm = forms.BooleanField(required=False, initial=False, label=_('Confirm uninstall'), help_text=_('Confirm removal of installed stock items'))
class Meta:
model = StockItem
fields = [
'location',
'note',
'confirm',
]
class EditStockItemForm(HelperForm):
""" Form for editing a StockItem object.
Note that not all fields can be edited here (even if they can be specified during creation.
location - Must be updated in a 'move' transaction
quantity - Must be updated in a 'stocktake' transaction
part - Cannot be edited after creation
TODO: Migrate this form to the modern API forms interface
"""
expiry_date = DatePickerFormField(
label=_('Expiry Date'),
help_text=_('Expiration date for this stock item'),
)
class Meta:
model = StockItem
fields = [
'supplier_part',
'serial',
'batch',
'status',
'expiry_date',
'purchase_price',
'packaging',
'link',
'delete_on_deplete',
'owner',
]
class TrackingEntryForm(HelperForm): class TrackingEntryForm(HelperForm):
""" """
Form for creating / editing a StockItemTracking object. Form for creating / editing a StockItemTracking object.

View File

@ -1142,7 +1142,7 @@ class StockItem(MPTTModel):
) )
@transaction.atomic @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. Uninstall this stock item from another item, into a location.

View File

@ -448,6 +448,48 @@ class InstallStockItemSerializer(serializers.Serializer):
) )
class UninstallStockItemSerializer(serializers.Serializer):
"""
API serializers for uninstalling an installed item from a stock item
"""
class Meta:
fields = [
'location',
'note',
]
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False, required=True, allow_null=False,
label=_('Location'),
help_text=_('Destination location for uninstalled item')
)
note = serializers.CharField(
label=_('Notes'),
help_text=_('Add transaction note (optional)'),
required=False, allow_blank=True,
)
def save(self):
item = self.context['item']
data = self.validated_data
request = self.context['request']
location = data['location']
note = data.get('note', '')
item.uninstall_into_location(
location,
request.user,
note
)
class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer): class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" """
Serializer for a simple tree view Serializer for a simple tree view

View File

@ -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?" %}
<br>
{% endblock %}

View File

@ -159,9 +159,12 @@
</div> </div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div class='btn-group'> <div id='installed-table-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id='installed-items' %}
</div> </div>
<table class='table table-striped table-condensed' id='installed-table'></table> </div>
<table class='table table-striped table-condensed' id='installed-table' data-toolbar='#installed-table-toolbar'></table>
</div> </div>
</div> </div>
@ -208,28 +211,6 @@
} }
); );
$('#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() { onPanelLoad('notes', function() {
setupNotesField( setupNotesField(
'stock-notes', 'stock-notes',

View File

@ -449,12 +449,9 @@ $('#stock-install-in').click(function() {
$('#stock-uninstall').click(function() { $('#stock-uninstall').click(function() {
launchModalForm( uninstallStockItem(
"{% url 'stock-item-uninstall' %}", {{ item.pk }},
{ {
data: {
'items[]': [{{ item.pk }}],
},
reload: true, reload: true,
} }
); );

View File

@ -1,28 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
<div class='alert alert-block alert-success'>
{% trans "The following stock items will be uninstalled" %}
</div>
<ul class='list-group'>
{% for item in stock_items %}
<li class='list-group-item'>
{% include "hover_image.html" with image=item.part.image hover=False %}
{{ item }}
</li>
{% endfor %}
</ul>
{% endblock %}
{% block form_data %}
{% for item in stock_items %}
<input type='hidden' name='stock-item-{{ item.pk }}' value='{{ item.pk }}'/>
{% endfor %}
{% endblock %}

View File

@ -29,6 +29,7 @@ class StockAPITestCase(InvenTreeAPITestCase):
fixtures = [ fixtures = [
'category', 'category',
'part', 'part',
'bom',
'company', 'company',
'location', 'location',
'supplier_part', 'supplier_part',
@ -643,6 +644,88 @@ class StockItemTest(StockAPITestCase):
data = self.get(url).data data = self.get(url).data
self.assertEqual(data['purchase_price_currency'], 'NZD') 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): class StocktakeTest(StockAPITestCase):
""" """

View File

@ -43,8 +43,6 @@ stock_urls = [
# Stock location # Stock location
re_path(r'^location/', include(location_urls)), 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)), re_path(r'^track/', include(stock_tracking_urls)),
# Individual stock items # Individual stock items

View File

@ -5,39 +5,24 @@ Django views for interacting with Stock app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.core.exceptions import ValidationError from datetime import datetime
from django.views.generic.edit import FormMixin
from django.views.generic import DetailView, ListView 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.urls import reverse
from django.http import HttpResponseRedirect 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 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 AjaxUpdateView, AjaxDeleteView, AjaxCreateView
from InvenTree.views import QRCodeView from InvenTree.views import QRCodeView
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.forms import ConfirmForm from InvenTree.forms import ConfirmForm
from InvenTree.helpers import str2bool 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 from .models import StockItem, StockLocation, StockItemTracking
import common.settings import common.settings
from common.models import InvenTreeSetting
from users.models import Owner
from . import forms as StockForms from . import forms as StockForms
@ -135,139 +120,6 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
return super().get(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): class StockLocationQRCode(QRCodeView):
""" View for displaying a QR code for a StockLocation object """ """ View for displaying a QR code for a StockLocation object """
@ -366,261 +218,6 @@ class StockItemQRCode(QRCodeView):
return None 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): class StockItemConvert(AjaxUpdateView):
""" """
View for 'converting' a StockItem to a variant of its current part. View for 'converting' a StockItem to a variant of its current part.
@ -655,435 +252,6 @@ class StockItemConvert(AjaxUpdateView):
return stock_item 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=<id>)
- 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): class StockLocationDelete(AjaxDeleteView):
""" """
View to delete a StockLocation View to delete a StockLocation

View File

@ -21,6 +21,7 @@
/* exported /* exported
allocateStockToBuild, allocateStockToBuild,
autoAllocateStockToBuild, autoAllocateStockToBuild,
cancelBuildOrder,
completeBuildOrder, completeBuildOrder,
createBuildOutput, createBuildOutput,
editBuildOrder, 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 = `
<div class='alert alert-block alert-info'>
{% trans "Are you sure you wish to cancel this build?" %}
</div>`;
if (opts.context.has_allocated_stock) {
html += `
<div class='alert alert-block alert-warning'>
{% trans "Stock items have been allocated to this build order" %}
</div>`;
}
if (opts.context.incomplete_outputs) {
html += `
<div class='alert alert-block alert-warning'>
{% trans "There are incomplete outputs remaining for this build order" %}
</div>`;
}
return html;
},
onSuccess: function(response) {
handleFormSuccess(response, options);
}
}
);
}
/* Construct a form to "complete" (finish) a build order */ /* Construct a form to "complete" (finish) a build order */
function completeBuildOrder(build_id, options={}) { function completeBuildOrder(build_id, options={}) {

View File

@ -123,6 +123,9 @@ function getApiEndpointOptions(url, callback) {
return; return;
} }
// Include extra context information in the request
url += '?context=true';
// Return the ajax request object // Return the ajax request object
$.ajax({ $.ajax({
url: url, url: url,
@ -335,6 +338,9 @@ function constructForm(url, options) {
// Request OPTIONS endpoint from the API // Request OPTIONS endpoint from the API
getApiEndpointOptions(url, function(OPTIONS) { 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, * Determine what "type" of form we want to construct,
* based on the requested action. * based on the requested action.
@ -527,7 +533,14 @@ function constructFormBody(fields, options) {
$(modal).find('#form-content').html(html); $(modal).find('#form-content').html(html);
if (options.preFormContent) { 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) { if (options.postFormContent) {

View File

@ -81,7 +81,7 @@ function renderStockItem(name, data, parameters={}, options={}) {
var part_detail = ''; var part_detail = '';
if (render_part_detail) { if (render_part_detail && data.part_detail) {
part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `; part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `;
} }

View File

@ -20,11 +20,15 @@
/* exported /* exported
allocateStockToSalesOrder, allocateStockToSalesOrder,
cancelPurchaseOrder,
cancelSalesOrder,
completePurchaseOrder,
completeShipment, completeShipment,
createSalesOrder, createSalesOrder,
createSalesOrderShipment, createSalesOrderShipment,
editPurchaseOrderLineItem, editPurchaseOrderLineItem,
exportOrder, exportOrder,
issuePurchaseOrder,
loadPurchaseOrderLineItemTable, loadPurchaseOrderLineItemTable,
loadPurchaseOrderExtraLineTable loadPurchaseOrderExtraLineTable
loadPurchaseOrderTable, 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 = `
<div class='alert alert-block alert-info'>
{% trans "Mark this order as complete?" %}
</div>`;
if (opts.context.is_complete) {
html += `
<div class='alert alert-block alert-success'>
{% trans "All line items have been received" %}
</div>`;
} else {
html += `
<div class='alert alert-block alert-warning'>
{% trans 'This order has line items which have not been marked as received.' %}</br>
{% trans 'Completing this order means that the order and line items will no longer be editable.' %}
</div>`;
}
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 = `
<div class='alert alert-info alert-block'>
{% trans "Are you sure you wish to cancel this purchase order?" %}
</div>`;
if (!opts.context.can_cancel) {
html += `
<div class='alert alert-danger alert-block'>
{% trans "This purchase order can not be cancelled" %}
</div>`;
}
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 = `
<div class='alert alert-block alert-warning'>
{% trans 'After placing this purchase order, line items will no longer be editable.' %}
</div>`;
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 = `
<div class='alert alert-block alert-warning'>
{% trans "Cancelling this order means that the order will no longer be editable." %}
</div>`;
return html;
},
onSuccess: function(response) {
handleFormSuccess(response, options);
}
}
);
}
// Open a dialog to create a new sales order shipment // Open a dialog to create a new sales order shipment
function createSalesOrderShipment(options={}) { function createSalesOrderShipment(options={}) {

View File

@ -57,6 +57,7 @@
stockItemFields, stockItemFields,
stockLocationFields, stockLocationFields,
stockStatusCodes, stockStatusCodes,
uninstallStockItem,
*/ */
@ -2630,13 +2631,10 @@ function loadInstalledInTable(table, options) {
table.find('.button-uninstall').click(function() { table.find('.button-uninstall').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
launchModalForm( uninstallStockItem(
'{% url "stock-item-uninstall" %}', pk,
{ {
data: { onSuccess: function(response) {
'items[]': pk,
},
success: function() {
table.bootstrapTable('refresh'); 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 += `
<div class='alert alert-block alert-info'>
{% trans "Select stock item to uninstall" %}
</div>`;
}
return html;
},
onSuccess: function(response) {
handleFormSuccess(response, options);
}
}
);
}
/* /*
* Launch a dialog to install a stock item into another stock item * Launch a dialog to install a stock item into another stock item
*/ */