mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into matmair/issue2385
This commit is contained in:
commit
6bd997ffad
@ -4,11 +4,15 @@ InvenTree API version information
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 43
|
||||
INVENTREE_API_VERSION = 44
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v44 -> 2022-05-04 : https://github.com/inventree/InvenTree/pull/2931
|
||||
- Converting more server-side rendered forms to the API
|
||||
- Exposes more core functionality to API endpoints
|
||||
|
||||
v43 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2875
|
||||
- Adds API detail endpoint for PartSalePrice model
|
||||
- Adds API detail endpoint for PartInternalPrice model
|
||||
|
@ -9,6 +9,8 @@ from rest_framework.metadata import SimpleMetadata
|
||||
from rest_framework.utils import model_meta
|
||||
from rest_framework.fields import empty
|
||||
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
import users.models
|
||||
|
||||
|
||||
@ -37,6 +39,22 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
metadata = super().determine_metadata(request, view)
|
||||
|
||||
"""
|
||||
Custom context information to pass through to the OPTIONS endpoint,
|
||||
if the "context=True" is supplied to the OPTIONS requst
|
||||
|
||||
Serializer class can supply context data by defining a get_context_data() method (no arguments)
|
||||
"""
|
||||
|
||||
context = {}
|
||||
|
||||
if str2bool(request.query_params.get('context', False)):
|
||||
|
||||
if hasattr(self.serializer, 'get_context_data'):
|
||||
context = self.serializer.get_context_data()
|
||||
|
||||
metadata['context'] = context
|
||||
|
||||
user = request.user
|
||||
|
||||
if user is None:
|
||||
@ -99,6 +117,8 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
to any fields whose Meta.model specifies a default value
|
||||
"""
|
||||
|
||||
self.serializer = serializer
|
||||
|
||||
serializer_info = super().get_serializer_info(serializer)
|
||||
|
||||
model_class = None
|
||||
|
@ -233,7 +233,24 @@ class BuildUnallocate(generics.CreateAPIView):
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildOutputCreate(generics.CreateAPIView):
|
||||
class BuildOrderContextMixin:
|
||||
""" Mixin class which adds build order as serializer context variable """
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
ctx['request'] = self.request
|
||||
ctx['to_complete'] = True
|
||||
|
||||
try:
|
||||
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildOutputCreate(BuildOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for creating new build output(s)
|
||||
"""
|
||||
@ -242,21 +259,8 @@ class BuildOutputCreate(generics.CreateAPIView):
|
||||
|
||||
serializer_class = build.serializers.BuildOutputCreateSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
ctx['request'] = self.request
|
||||
ctx['to_complete'] = True
|
||||
|
||||
try:
|
||||
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildOutputComplete(generics.CreateAPIView):
|
||||
class BuildOutputComplete(BuildOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for completing build outputs
|
||||
"""
|
||||
@ -265,21 +269,8 @@ class BuildOutputComplete(generics.CreateAPIView):
|
||||
|
||||
serializer_class = build.serializers.BuildOutputCompleteSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
ctx['request'] = self.request
|
||||
ctx['to_complete'] = True
|
||||
|
||||
try:
|
||||
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildOutputDelete(generics.CreateAPIView):
|
||||
class BuildOutputDelete(BuildOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for deleting multiple build outputs
|
||||
"""
|
||||
@ -288,20 +279,8 @@ class BuildOutputDelete(generics.CreateAPIView):
|
||||
|
||||
serializer_class = build.serializers.BuildOutputDeleteSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
ctx['request'] = self.request
|
||||
|
||||
try:
|
||||
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildFinish(generics.CreateAPIView):
|
||||
class BuildFinish(BuildOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for marking a build as finished (completed)
|
||||
"""
|
||||
@ -310,20 +289,8 @@ class BuildFinish(generics.CreateAPIView):
|
||||
|
||||
serializer_class = build.serializers.BuildCompleteSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
ctx['request'] = self.request
|
||||
|
||||
try:
|
||||
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildAutoAllocate(generics.CreateAPIView):
|
||||
class BuildAutoAllocate(BuildOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for 'automatically' allocating stock against a build order.
|
||||
|
||||
@ -337,24 +304,8 @@ class BuildAutoAllocate(generics.CreateAPIView):
|
||||
|
||||
serializer_class = build.serializers.BuildAutoAllocationSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""
|
||||
Provide the Build object to the serializer context
|
||||
"""
|
||||
|
||||
context = super().get_serializer_context()
|
||||
|
||||
try:
|
||||
context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class BuildAllocate(generics.CreateAPIView):
|
||||
class BuildAllocate(BuildOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocate stock items to a build order
|
||||
|
||||
@ -370,21 +321,12 @@ class BuildAllocate(generics.CreateAPIView):
|
||||
|
||||
serializer_class = build.serializers.BuildAllocationSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""
|
||||
Provide the Build object to the serializer context
|
||||
"""
|
||||
|
||||
context = super().get_serializer_context()
|
||||
class BuildCancel(BuildOrderContextMixin, generics.CreateAPIView):
|
||||
""" API endpoint for cancelling a BuildOrder """
|
||||
|
||||
try:
|
||||
context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
queryset = Build.objects.all()
|
||||
serializer_class = build.serializers.BuildCancelSerializer
|
||||
|
||||
|
||||
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'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
|
||||
re_path(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
|
||||
re_path(r'^cancel/', BuildCancel.as_view(), name='api-build-cancel'),
|
||||
re_path(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||
re_path(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||
])),
|
||||
|
@ -5,22 +5,3 @@ Django Forms for interacting with Build objects
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django import forms
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
|
||||
from .models import Build
|
||||
|
||||
|
||||
class CancelBuildForm(HelperForm):
|
||||
""" Form for cancelling a build """
|
||||
|
||||
confirm_cancel = forms.BooleanField(required=False, label=_('Confirm cancel'), help_text=_('Confirm build cancellation'))
|
||||
|
||||
class Meta:
|
||||
model = Build
|
||||
fields = [
|
||||
'confirm_cancel'
|
||||
]
|
||||
|
@ -479,6 +479,16 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
return outputs
|
||||
|
||||
@property
|
||||
def complete_count(self):
|
||||
|
||||
quantity = 0
|
||||
|
||||
for output in self.complete_outputs:
|
||||
quantity += output.quantity
|
||||
|
||||
return quantity
|
||||
|
||||
@property
|
||||
def incomplete_outputs(self):
|
||||
"""
|
||||
@ -588,7 +598,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
trigger_event('build.completed', id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def cancelBuild(self, user):
|
||||
def cancel_build(self, user, **kwargs):
|
||||
""" Mark the Build as CANCELLED
|
||||
|
||||
- Delete any pending BuildItem objects (but do not remove items from stock)
|
||||
@ -596,8 +606,23 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
- Save the Build object
|
||||
"""
|
||||
|
||||
for item in self.allocated_stock.all():
|
||||
item.delete()
|
||||
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
|
||||
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
|
||||
|
||||
# Handle stock allocations
|
||||
for build_item in self.allocated_stock.all():
|
||||
|
||||
if remove_allocated_stock:
|
||||
build_item.complete_allocation(user)
|
||||
|
||||
build_item.delete()
|
||||
|
||||
# Remove incomplete outputs (if required)
|
||||
if remove_incomplete_outputs:
|
||||
outputs = self.build_outputs.filter(is_building=True)
|
||||
|
||||
for output in outputs:
|
||||
output.delete()
|
||||
|
||||
# Date of 'completion' is the date the build was cancelled
|
||||
self.completion_date = datetime.now().date()
|
||||
@ -1025,6 +1050,24 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
# All parts must be fully allocated!
|
||||
return True
|
||||
|
||||
def is_partially_allocated(self, output):
|
||||
"""
|
||||
Returns True if the particular build output is (at least) partially allocated
|
||||
"""
|
||||
|
||||
# If output is not specified, we are talking about "untracked" items
|
||||
if output is None:
|
||||
bom_items = self.untracked_bom_items
|
||||
else:
|
||||
bom_items = self.tracked_bom_items
|
||||
|
||||
for bom_item in bom_items:
|
||||
|
||||
if self.allocated_quantity(bom_item, output) > 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def are_untracked_parts_allocated(self):
|
||||
"""
|
||||
Returns True if the un-tracked parts are fully allocated for this BuildOrder
|
||||
|
@ -438,6 +438,52 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class BuildCancelSerializer(serializers.Serializer):
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'remove_allocated_stock',
|
||||
'remove_incomplete_outputs',
|
||||
]
|
||||
|
||||
def get_context_data(self):
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
return {
|
||||
'has_allocated_stock': build.is_partially_allocated(None),
|
||||
'incomplete_outputs': build.incomplete_count,
|
||||
'completed_outputs': build.complete_count,
|
||||
}
|
||||
|
||||
remove_allocated_stock = serializers.BooleanField(
|
||||
label=_('Remove Allocated Stock'),
|
||||
help_text=_('Subtract any stock which has already been allocated to this build'),
|
||||
required=False,
|
||||
default=False,
|
||||
)
|
||||
|
||||
remove_incomplete_outputs = serializers.BooleanField(
|
||||
label=_('Remove Incomplete Outputs'),
|
||||
help_text=_('Delete any build outputs which have not been completed'),
|
||||
required=False,
|
||||
default=False,
|
||||
)
|
||||
|
||||
def save(self):
|
||||
|
||||
build = self.context['build']
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
|
||||
build.cancel_build(
|
||||
request.user,
|
||||
remove_allocated_stock=data.get('remove_unallocated_stock', False),
|
||||
remove_incomplete_outputs=data.get('remove_incomplete_outputs', False),
|
||||
)
|
||||
|
||||
|
||||
class BuildCompleteSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for marking a BuildOrder as complete
|
||||
|
@ -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>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
@ -214,11 +214,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
});
|
||||
|
||||
$("#build-cancel").click(function() {
|
||||
launchModalForm("{% url 'build-cancel' build.id %}",
|
||||
|
||||
cancelBuildOrder(
|
||||
{{ build.pk }},
|
||||
{
|
||||
reload: true,
|
||||
submit_text: '{% trans "Cancel Build" %}',
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#build-complete").on('click', function() {
|
||||
|
@ -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 %}
|
@ -5,6 +5,12 @@ from datetime import datetime, timedelta
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework import status
|
||||
|
||||
from part.models import Part
|
||||
from build.models import Build, BuildItem
|
||||
from stock.models import StockItem
|
||||
@ -13,6 +19,84 @@ from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
|
||||
|
||||
class TestBuildAPI(APITestCase):
|
||||
"""
|
||||
Series of tests for the Build DRF API
|
||||
- Tests for Build API
|
||||
- Tests for BuildItem API
|
||||
"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'build',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
# Create a user for auth
|
||||
user = get_user_model()
|
||||
self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
|
||||
g = Group.objects.create(name='builders')
|
||||
self.user.groups.add(g)
|
||||
|
||||
for rule in g.rule_sets.all():
|
||||
if rule.name == 'build':
|
||||
rule.can_change = True
|
||||
rule.can_add = True
|
||||
rule.can_delete = True
|
||||
|
||||
rule.save()
|
||||
|
||||
g.save()
|
||||
|
||||
self.client.login(username='testuser', password='password')
|
||||
|
||||
def test_get_build_list(self):
|
||||
"""
|
||||
Test that we can retrieve list of build objects
|
||||
"""
|
||||
|
||||
url = reverse('api-build-list')
|
||||
response = self.client.get(url, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# Filter query by build status
|
||||
response = self.client.get(url, {'status': 40}, format='json')
|
||||
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
# Filter by "active" status
|
||||
response = self.client.get(url, {'active': True}, format='json')
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['pk'], 1)
|
||||
|
||||
response = self.client.get(url, {'active': False}, format='json')
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
# Filter by 'part' status
|
||||
response = self.client.get(url, {'part': 25}, format='json')
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
# Filter by an invalid part
|
||||
response = self.client.get(url, {'part': 99999}, format='json')
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
def test_get_build_item_list(self):
|
||||
""" Test that we can retrieve list of BuildItem objects """
|
||||
url = reverse('api-build-item-list')
|
||||
|
||||
response = self.client.get(url, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
# Test again, filtering by park ID
|
||||
response = self.client.get(url, {'part': '1'}, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class BuildAPITest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Series of tests for the Build DRF API
|
||||
@ -38,7 +122,7 @@ class BuildAPITest(InvenTreeAPITestCase):
|
||||
super().setUp()
|
||||
|
||||
|
||||
class BuildOutputCompleteTest(BuildAPITest):
|
||||
class BuildTest(BuildAPITest):
|
||||
"""
|
||||
Unit testing for the build complete API endpoint
|
||||
"""
|
||||
@ -206,6 +290,21 @@ class BuildOutputCompleteTest(BuildAPITest):
|
||||
# Build should have been marked as complete
|
||||
self.assertTrue(self.build.is_complete)
|
||||
|
||||
def test_cancel(self):
|
||||
""" Test that we can cancel a BuildOrder via the API """
|
||||
|
||||
bo = Build.objects.get(pk=1)
|
||||
|
||||
url = reverse('api-build-cancel', kwargs={'pk': bo.pk})
|
||||
|
||||
self.assertEqual(bo.status, BuildStatus.PENDING)
|
||||
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
bo.refresh_from_db()
|
||||
|
||||
self.assertEqual(bo.status, BuildStatus.CANCELLED)
|
||||
|
||||
|
||||
class BuildAllocationTest(BuildAPITest):
|
||||
"""
|
||||
|
@ -304,7 +304,7 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
"""
|
||||
self.allocate_stock(50, 50, 200, self.output_1)
|
||||
self.build.cancelBuild(None)
|
||||
self.build.cancel_build(None)
|
||||
|
||||
self.assertEqual(BuildItem.objects.count(), 0)
|
||||
"""
|
||||
|
@ -3,13 +3,10 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework import status
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .models import Build
|
||||
@ -107,89 +104,11 @@ class BuildTestSimple(TestCase):
|
||||
|
||||
self.assertEqual(build.status, BuildStatus.PENDING)
|
||||
|
||||
build.cancelBuild(self.user)
|
||||
build.cancel_build(self.user)
|
||||
|
||||
self.assertEqual(build.status, BuildStatus.CANCELLED)
|
||||
|
||||
|
||||
class TestBuildAPI(APITestCase):
|
||||
"""
|
||||
Series of tests for the Build DRF API
|
||||
- Tests for Build API
|
||||
- Tests for BuildItem API
|
||||
"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'build',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
# Create a user for auth
|
||||
user = get_user_model()
|
||||
self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
|
||||
g = Group.objects.create(name='builders')
|
||||
self.user.groups.add(g)
|
||||
|
||||
for rule in g.rule_sets.all():
|
||||
if rule.name == 'build':
|
||||
rule.can_change = True
|
||||
rule.can_add = True
|
||||
rule.can_delete = True
|
||||
|
||||
rule.save()
|
||||
|
||||
g.save()
|
||||
|
||||
self.client.login(username='testuser', password='password')
|
||||
|
||||
def test_get_build_list(self):
|
||||
"""
|
||||
Test that we can retrieve list of build objects
|
||||
"""
|
||||
|
||||
url = reverse('api-build-list')
|
||||
response = self.client.get(url, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# Filter query by build status
|
||||
response = self.client.get(url, {'status': 40}, format='json')
|
||||
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
# Filter by "active" status
|
||||
response = self.client.get(url, {'active': True}, format='json')
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['pk'], 1)
|
||||
|
||||
response = self.client.get(url, {'active': False}, format='json')
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
# Filter by 'part' status
|
||||
response = self.client.get(url, {'part': 25}, format='json')
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
# Filter by an invalid part
|
||||
response = self.client.get(url, {'part': 99999}, format='json')
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
def test_get_build_item_list(self):
|
||||
""" Test that we can retrieve list of BuildItem objects """
|
||||
url = reverse('api-build-item-list')
|
||||
|
||||
response = self.client.get(url, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
# Test again, filtering by park ID
|
||||
response = self.client.get(url, {'part': '1'}, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TestBuildViews(TestCase):
|
||||
""" Tests for Build app views """
|
||||
|
||||
@ -251,28 +170,3 @@ class TestBuildViews(TestCase):
|
||||
content = str(response.content)
|
||||
|
||||
self.assertIn(build.title, content)
|
||||
|
||||
def test_build_cancel(self):
|
||||
""" Test the build cancellation form """
|
||||
|
||||
url = reverse('build-cancel', args=(1,))
|
||||
|
||||
# Test without confirmation
|
||||
response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertFalse(data['form_valid'])
|
||||
|
||||
b = Build.objects.get(pk=1)
|
||||
self.assertEqual(b.status, 10) # Build status is still PENDING
|
||||
|
||||
# Test with confirmation
|
||||
response = self.client.post(url, {'confirm_cancel': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertTrue(data['form_valid'])
|
||||
|
||||
b = Build.objects.get(pk=1)
|
||||
self.assertEqual(b.status, 30) # Build status is now CANCELLED
|
||||
|
@ -7,7 +7,6 @@ from django.urls import include, re_path
|
||||
from . import views
|
||||
|
||||
build_detail_urls = [
|
||||
re_path(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
|
||||
re_path(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
||||
|
||||
re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||
|
@ -9,11 +9,9 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
from .models import Build
|
||||
from . import forms
|
||||
|
||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.views import AjaxDeleteView
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
|
||||
|
||||
@ -43,37 +41,6 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
|
||||
return context
|
||||
|
||||
|
||||
class BuildCancel(AjaxUpdateView):
|
||||
""" View to cancel a Build.
|
||||
Provides a cancellation information dialog
|
||||
"""
|
||||
|
||||
model = Build
|
||||
ajax_template_name = 'build/cancel.html'
|
||||
ajax_form_title = _('Cancel Build')
|
||||
context_object_name = 'build'
|
||||
form_class = forms.CancelBuildForm
|
||||
|
||||
def validate(self, build, form, **kwargs):
|
||||
|
||||
confirm = str2bool(form.cleaned_data.get('confirm_cancel', False))
|
||||
|
||||
if not confirm:
|
||||
form.add_error('confirm_cancel', _('Confirm build cancellation'))
|
||||
|
||||
def save(self, build, form, **kwargs):
|
||||
"""
|
||||
Cancel the build.
|
||||
"""
|
||||
|
||||
build.cancelBuild(self.request.user)
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'danger': _('Build was cancelled')
|
||||
}
|
||||
|
||||
|
||||
class BuildDetail(InvenTreeRoleMixin, DetailView):
|
||||
"""
|
||||
Detail view of a single Build object.
|
||||
|
@ -286,7 +286,58 @@ class PurchaseOrderDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
return queryset
|
||||
|
||||
|
||||
class PurchaseOrderReceive(generics.CreateAPIView):
|
||||
class PurchaseOrderContextMixin:
|
||||
""" Mixin to add purchase order object as serializer context variable """
|
||||
|
||||
def get_serializer_context(self):
|
||||
""" Add the PurchaseOrder object to the serializer context """
|
||||
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# Pass the purchase order through to the serializer for validation
|
||||
try:
|
||||
context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class PurchaseOrderCancel(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to 'cancel' a purchase order.
|
||||
|
||||
The purchase order must be in a state which can be cancelled
|
||||
"""
|
||||
|
||||
queryset = models.PurchaseOrder.objects.all()
|
||||
|
||||
serializer_class = serializers.PurchaseOrderCancelSerializer
|
||||
|
||||
|
||||
class PurchaseOrderComplete(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to 'complete' a purchase order
|
||||
"""
|
||||
|
||||
queryset = models.PurchaseOrder.objects.all()
|
||||
|
||||
serializer_class = serializers.PurchaseOrderCompleteSerializer
|
||||
|
||||
|
||||
class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to 'complete' a purchase order
|
||||
"""
|
||||
|
||||
queryset = models.PurchaseOrder.objects.all()
|
||||
|
||||
serializer_class = serializers.PurchaseOrderIssueSerializer
|
||||
|
||||
|
||||
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to receive stock items against a purchase order.
|
||||
|
||||
@ -303,20 +354,6 @@ class PurchaseOrderReceive(generics.CreateAPIView):
|
||||
|
||||
serializer_class = serializers.PurchaseOrderReceiveSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# Pass the purchase order through to the serializer for validation
|
||||
try:
|
||||
context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
|
||||
"""
|
||||
@ -834,13 +871,8 @@ class SalesOrderLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = serializers.SalesOrderLineItemSerializer
|
||||
|
||||
|
||||
class SalesOrderComplete(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for manually marking a SalesOrder as "complete".
|
||||
"""
|
||||
|
||||
queryset = models.SalesOrder.objects.all()
|
||||
serializer_class = serializers.SalesOrderCompleteSerializer
|
||||
class SalesOrderContextMixin:
|
||||
""" Mixin to add sales order object as serializer context variable """
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
@ -856,7 +888,22 @@ class SalesOrderComplete(generics.CreateAPIView):
|
||||
return ctx
|
||||
|
||||
|
||||
class SalesOrderAllocateSerials(generics.CreateAPIView):
|
||||
class SalesOrderCancel(SalesOrderContextMixin, generics.CreateAPIView):
|
||||
|
||||
queryset = models.SalesOrder.objects.all()
|
||||
serializer_class = serializers.SalesOrderCancelSerializer
|
||||
|
||||
|
||||
class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for manually marking a SalesOrder as "complete".
|
||||
"""
|
||||
|
||||
queryset = models.SalesOrder.objects.all()
|
||||
serializer_class = serializers.SalesOrderCompleteSerializer
|
||||
|
||||
|
||||
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocation stock items against a SalesOrder,
|
||||
by specifying serial numbers.
|
||||
@ -865,22 +912,8 @@ class SalesOrderAllocateSerials(generics.CreateAPIView):
|
||||
queryset = models.SalesOrder.objects.none()
|
||||
serializer_class = serializers.SalesOrderSerialAllocationSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
# Pass through the SalesOrder object to the serializer
|
||||
try:
|
||||
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
ctx['request'] = self.request
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class SalesOrderAllocate(generics.CreateAPIView):
|
||||
class SalesOrderAllocate(SalesOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocate stock items against a SalesOrder
|
||||
|
||||
@ -891,20 +924,6 @@ class SalesOrderAllocate(generics.CreateAPIView):
|
||||
queryset = models.SalesOrder.objects.none()
|
||||
serializer_class = serializers.SalesOrderShipmentAllocationSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
# Pass through the SalesOrder object to the serializer
|
||||
try:
|
||||
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
ctx['request'] = self.request
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class SalesOrderAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
@ -1106,7 +1125,10 @@ order_api_urls = [
|
||||
|
||||
# Individual purchase order detail URLs
|
||||
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'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
|
||||
re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
|
||||
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
|
||||
])),
|
||||
|
||||
@ -1143,6 +1165,7 @@ order_api_urls = [
|
||||
|
||||
# Sales order detail view
|
||||
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'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
||||
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
|
||||
|
@ -8,60 +8,12 @@ from __future__ import unicode_literals
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import InvenTreeMoneyField
|
||||
|
||||
from InvenTree.helpers import clean_decimal
|
||||
|
||||
from common.forms import MatchItemForm
|
||||
|
||||
from .models import PurchaseOrder
|
||||
from .models import SalesOrder
|
||||
|
||||
|
||||
class IssuePurchaseOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=True, initial=False, label=_('Confirm'), help_text=_('Place order'))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class CompletePurchaseOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_("Mark order as complete"))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class CancelPurchaseOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Cancel order'))
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class CancelSalesOrderForm(HelperForm):
|
||||
|
||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Cancel order'))
|
||||
|
||||
class Meta:
|
||||
model = SalesOrder
|
||||
fields = [
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class OrderMatchItemForm(MatchItemForm):
|
||||
""" Override MatchItemForm fields """
|
||||
|
@ -381,6 +381,7 @@ class PurchaseOrder(Order):
|
||||
PurchaseOrderStatus.PENDING
|
||||
]
|
||||
|
||||
@transaction.atomic
|
||||
def cancel_order(self):
|
||||
""" Marks the PurchaseOrder as CANCELLED. """
|
||||
|
||||
|
@ -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):
|
||||
|
||||
@staticmethod
|
||||
@ -974,6 +1040,25 @@ class SalesOrderCompleteSerializer(serializers.Serializer):
|
||||
order.complete_order(user)
|
||||
|
||||
|
||||
class SalesOrderCancelSerializer(serializers.Serializer):
|
||||
""" Serializer for marking a SalesOrder as cancelled
|
||||
"""
|
||||
|
||||
def get_context_data(self):
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
return {
|
||||
'can_cancel': order.can_cancel(),
|
||||
}
|
||||
|
||||
def save(self):
|
||||
|
||||
order = self.context['order']
|
||||
|
||||
order.cancel_order()
|
||||
|
||||
|
||||
class SalesOrderSerialAllocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for allocation of serial numbers against a sales order / shipment
|
||||
|
@ -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 %}
|
@ -192,10 +192,14 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
$("#place-order").click(function() {
|
||||
launchModalForm("{% url 'po-issue' order.id %}",
|
||||
|
||||
issuePurchaseOrder(
|
||||
{{ order.pk }},
|
||||
{
|
||||
reload: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
@ -258,15 +262,27 @@ $("#receive-order").click(function() {
|
||||
});
|
||||
|
||||
$("#complete-order").click(function() {
|
||||
launchModalForm("{% url 'po-complete' order.id %}", {
|
||||
reload: true,
|
||||
});
|
||||
|
||||
completePurchaseOrder(
|
||||
{{ order.pk }},
|
||||
{
|
||||
onSuccess: function() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#cancel-order").click(function() {
|
||||
launchModalForm("{% url 'po-cancel' order.id %}", {
|
||||
reload: true,
|
||||
});
|
||||
|
||||
cancelPurchaseOrder(
|
||||
{{ order.pk }},
|
||||
{
|
||||
onSuccess: function() {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
$("#export-order").click(function() {
|
||||
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -224,9 +224,13 @@ $("#edit-order").click(function() {
|
||||
});
|
||||
|
||||
$("#cancel-order").click(function() {
|
||||
launchModalForm("{% url 'so-cancel' order.id %}", {
|
||||
|
||||
cancelSalesOrder(
|
||||
{{ order.pk }},
|
||||
{
|
||||
reload: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#complete-order").click(function() {
|
||||
|
@ -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 %}
|
@ -9,7 +9,7 @@ from rest_framework import status
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
@ -239,6 +239,73 @@ class PurchaseOrderTest(OrderTest):
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
def test_po_cancel(self):
|
||||
"""
|
||||
Test the PurchaseOrderCancel API endpoint
|
||||
"""
|
||||
|
||||
po = models.PurchaseOrder.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(po.status, PurchaseOrderStatus.PENDING)
|
||||
|
||||
url = reverse('api-po-cancel', kwargs={'pk': po.pk})
|
||||
|
||||
# Try to cancel the PO, but without reqiured permissions
|
||||
self.post(url, {}, expected_code=403)
|
||||
|
||||
self.assignRole('purchase_order.add')
|
||||
|
||||
self.post(
|
||||
url,
|
||||
{},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
po.refresh_from_db()
|
||||
|
||||
self.assertEqual(po.status, PurchaseOrderStatus.CANCELLED)
|
||||
|
||||
# Try to cancel again (should fail)
|
||||
self.post(url, {}, expected_code=400)
|
||||
|
||||
def test_po_complete(self):
|
||||
""" Test the PurchaseOrderComplete API endpoint """
|
||||
|
||||
po = models.PurchaseOrder.objects.get(pk=3)
|
||||
|
||||
url = reverse('api-po-complete', kwargs={'pk': po.pk})
|
||||
|
||||
self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
|
||||
|
||||
# Try to complete the PO, without required permissions
|
||||
self.post(url, {}, expected_code=403)
|
||||
|
||||
self.assignRole('purchase_order.add')
|
||||
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
po.refresh_from_db()
|
||||
|
||||
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
|
||||
|
||||
def test_po_issue(self):
|
||||
""" Test the PurchaseOrderIssue API endpoint """
|
||||
|
||||
po = models.PurchaseOrder.objects.get(pk=2)
|
||||
|
||||
url = reverse('api-po-issue', kwargs={'pk': po.pk})
|
||||
|
||||
# Try to issue the PO, without required permissions
|
||||
self.post(url, {}, expected_code=403)
|
||||
|
||||
self.assignRole('purchase_order.add')
|
||||
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
po.refresh_from_db()
|
||||
|
||||
self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
|
||||
|
||||
|
||||
class PurchaseOrderReceiveTest(OrderTest):
|
||||
"""
|
||||
@ -788,6 +855,26 @@ class SalesOrderTest(OrderTest):
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
def test_so_cancel(self):
|
||||
""" Test API endpoint for cancelling a SalesOrder """
|
||||
|
||||
so = models.SalesOrder.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(so.status, SalesOrderStatus.PENDING)
|
||||
|
||||
url = reverse('api-so-cancel', kwargs={'pk': so.pk})
|
||||
|
||||
# Try to cancel, without permission
|
||||
self.post(url, {}, expected_code=403)
|
||||
|
||||
self.assignRole('sales_order.add')
|
||||
|
||||
self.post(url, {}, expected_code=201)
|
||||
|
||||
so.refresh_from_db()
|
||||
|
||||
self.assertEqual(so.status, SalesOrderStatus.CANCELLED)
|
||||
|
||||
|
||||
class SalesOrderAllocateTest(OrderTest):
|
||||
"""
|
||||
|
@ -8,12 +8,6 @@ from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
|
||||
from .models import PurchaseOrder
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class OrderViewTestCase(TestCase):
|
||||
|
||||
@ -76,30 +70,3 @@ class POTests(OrderViewTestCase):
|
||||
|
||||
# Response should be streaming-content (file download)
|
||||
self.assertIn('streaming_content', dir(response))
|
||||
|
||||
def test_po_issue(self):
|
||||
""" Test PurchaseOrderIssue view """
|
||||
|
||||
url = reverse('po-issue', args=(1,))
|
||||
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
||||
|
||||
# Test without confirmation
|
||||
response = self.client.post(url, {'confirm': 0}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = json.loads(response.content)
|
||||
|
||||
self.assertFalse(data['form_valid'])
|
||||
|
||||
# Test WITH confirmation
|
||||
response = self.client.post(url, {'confirm': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertTrue(data['form_valid'])
|
||||
|
||||
# Test that the order was actually placed
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
|
||||
|
@ -11,10 +11,6 @@ from . import views
|
||||
|
||||
purchase_order_detail_urls = [
|
||||
|
||||
re_path(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
|
||||
re_path(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'),
|
||||
re_path(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'),
|
||||
|
||||
re_path(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'),
|
||||
re_path(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'),
|
||||
|
||||
@ -33,7 +29,6 @@ purchase_order_urls = [
|
||||
]
|
||||
|
||||
sales_order_detail_urls = [
|
||||
re_path(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'),
|
||||
re_path(r'^export/', views.SalesOrderExport.as_view(), name='so-export'),
|
||||
|
||||
re_path(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'),
|
||||
|
@ -30,9 +30,8 @@ from common.files import FileManager
|
||||
from . import forms as order_forms
|
||||
from part.views import PartPricing
|
||||
|
||||
from InvenTree.views import AjaxView, AjaxUpdateView
|
||||
from InvenTree.helpers import DownloadFile, str2bool
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from InvenTree.helpers import DownloadFile
|
||||
from InvenTree.views import InvenTreeRoleMixin, AjaxView
|
||||
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
@ -87,123 +86,6 @@ class SalesOrderDetail(InvenTreeRoleMixin, DetailView):
|
||||
template_name = 'order/sales_order_detail.html'
|
||||
|
||||
|
||||
class PurchaseOrderCancel(AjaxUpdateView):
|
||||
""" View for cancelling a purchase order """
|
||||
|
||||
model = PurchaseOrder
|
||||
ajax_form_title = _('Cancel Order')
|
||||
ajax_template_name = 'order/order_cancel.html'
|
||||
form_class = order_forms.CancelPurchaseOrderForm
|
||||
|
||||
def validate(self, order, form, **kwargs):
|
||||
|
||||
confirm = str2bool(form.cleaned_data.get('confirm', False))
|
||||
|
||||
if not confirm:
|
||||
form.add_error('confirm', _('Confirm order cancellation'))
|
||||
|
||||
if not order.can_cancel():
|
||||
form.add_error(None, _('Order cannot be cancelled'))
|
||||
|
||||
def save(self, order, form, **kwargs):
|
||||
"""
|
||||
Cancel the PurchaseOrder
|
||||
"""
|
||||
|
||||
order.cancel_order()
|
||||
|
||||
|
||||
class SalesOrderCancel(AjaxUpdateView):
|
||||
""" View for cancelling a sales order """
|
||||
|
||||
model = SalesOrder
|
||||
ajax_form_title = _("Cancel sales order")
|
||||
ajax_template_name = "order/sales_order_cancel.html"
|
||||
form_class = order_forms.CancelSalesOrderForm
|
||||
|
||||
def validate(self, order, form, **kwargs):
|
||||
|
||||
confirm = str2bool(form.cleaned_data.get('confirm', False))
|
||||
|
||||
if not confirm:
|
||||
form.add_error('confirm', _('Confirm order cancellation'))
|
||||
|
||||
if not order.can_cancel():
|
||||
form.add_error(None, _('Order cannot be cancelled'))
|
||||
|
||||
def save(self, order, form, **kwargs):
|
||||
"""
|
||||
Once the form has been validated, cancel the SalesOrder
|
||||
"""
|
||||
|
||||
order.cancel_order()
|
||||
|
||||
|
||||
class PurchaseOrderIssue(AjaxUpdateView):
|
||||
""" View for changing a purchase order from 'PENDING' to 'ISSUED' """
|
||||
|
||||
model = PurchaseOrder
|
||||
ajax_form_title = _('Issue Order')
|
||||
ajax_template_name = "order/order_issue.html"
|
||||
form_class = order_forms.IssuePurchaseOrderForm
|
||||
|
||||
def validate(self, order, form, **kwargs):
|
||||
|
||||
confirm = str2bool(self.request.POST.get('confirm', False))
|
||||
|
||||
if not confirm:
|
||||
form.add_error('confirm', _('Confirm order placement'))
|
||||
|
||||
def save(self, order, form, **kwargs):
|
||||
"""
|
||||
Once the form has been validated, place the order.
|
||||
"""
|
||||
order.place_order()
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': _('Purchase order issued')
|
||||
}
|
||||
|
||||
|
||||
class PurchaseOrderComplete(AjaxUpdateView):
|
||||
""" View for marking a PurchaseOrder as complete.
|
||||
"""
|
||||
|
||||
form_class = order_forms.CompletePurchaseOrderForm
|
||||
model = PurchaseOrder
|
||||
ajax_template_name = "order/order_complete.html"
|
||||
ajax_form_title = _("Complete Order")
|
||||
context_object_name = 'order'
|
||||
|
||||
def get_context_data(self):
|
||||
|
||||
ctx = {
|
||||
'order': self.get_object(),
|
||||
}
|
||||
|
||||
return ctx
|
||||
|
||||
def validate(self, order, form, **kwargs):
|
||||
|
||||
confirm = str2bool(form.cleaned_data.get('confirm', False))
|
||||
|
||||
if not confirm:
|
||||
form.add_error('confirm', _('Confirm order completion'))
|
||||
|
||||
def save(self, order, form, **kwargs):
|
||||
"""
|
||||
Complete the PurchaseOrder
|
||||
"""
|
||||
|
||||
order.complete_order()
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': _('Purchase order completed')
|
||||
}
|
||||
|
||||
|
||||
class PurchaseOrderUpload(FileManagementFormView):
|
||||
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
|
||||
|
||||
|
@ -95,24 +95,6 @@ class EditPartParameterTemplateForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class EditCategoryForm(HelperForm):
|
||||
""" Form for editing a PartCategory object """
|
||||
|
||||
field_prefix = {
|
||||
'default_keywords': 'fa-key',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = PartCategory
|
||||
fields = [
|
||||
'parent',
|
||||
'name',
|
||||
'description',
|
||||
'default_location',
|
||||
'default_keywords',
|
||||
]
|
||||
|
||||
|
||||
class EditCategoryParameterTemplateForm(HelperForm):
|
||||
""" Form for editing a PartCategoryParameterTemplate object """
|
||||
|
||||
|
@ -491,7 +491,7 @@ class Part(MPTTModel):
|
||||
def __str__(self):
|
||||
return f"{self.full_name} - {self.description}"
|
||||
|
||||
def get_parts_in_bom(self):
|
||||
def get_parts_in_bom(self, **kwargs):
|
||||
"""
|
||||
Return a list of all parts in the BOM for this part.
|
||||
Takes into account substitutes, variant parts, and inherited BOM items
|
||||
@ -499,27 +499,22 @@ class Part(MPTTModel):
|
||||
|
||||
parts = set()
|
||||
|
||||
for bom_item in self.get_bom_items():
|
||||
for bom_item in self.get_bom_items(**kwargs):
|
||||
for part in bom_item.get_valid_parts_for_allocation():
|
||||
parts.add(part)
|
||||
|
||||
return parts
|
||||
|
||||
def check_if_part_in_bom(self, other_part):
|
||||
def check_if_part_in_bom(self, other_part, **kwargs):
|
||||
"""
|
||||
Check if the other_part is in the BOM for this part.
|
||||
Check if the other_part is in the BOM for *this* part.
|
||||
|
||||
Note:
|
||||
- Accounts for substitute parts
|
||||
- Accounts for variant BOMs
|
||||
"""
|
||||
|
||||
for bom_item in self.get_bom_items():
|
||||
if other_part in bom_item.get_valid_parts_for_allocation():
|
||||
return True
|
||||
|
||||
# No matches found
|
||||
return False
|
||||
return other_part in self.get_parts_in_bom(**kwargs)
|
||||
|
||||
def check_add_to_bom(self, parent, raise_error=False, recursive=True):
|
||||
"""
|
||||
|
@ -43,7 +43,7 @@ class BomItemTest(TestCase):
|
||||
|
||||
self.assertIn(self.orphan, parts)
|
||||
|
||||
# TODO: Tests for multi-level BOMs
|
||||
self.assertTrue(self.bob.check_if_part_in_bom(self.orphan))
|
||||
|
||||
def test_used_in(self):
|
||||
self.assertEqual(self.bob.used_in_count, 1)
|
||||
|
@ -1001,45 +1001,6 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
|
||||
return context
|
||||
|
||||
|
||||
class CategoryEdit(AjaxUpdateView):
|
||||
"""
|
||||
Update view to edit a PartCategory
|
||||
"""
|
||||
|
||||
model = PartCategory
|
||||
form_class = part_forms.EditCategoryForm
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Edit Part Category')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(CategoryEdit, self).get_context_data(**kwargs).copy()
|
||||
|
||||
try:
|
||||
context['category'] = self.get_object()
|
||||
except:
|
||||
pass
|
||||
|
||||
return context
|
||||
|
||||
def get_form(self):
|
||||
""" Customize form data for PartCategory editing.
|
||||
|
||||
Limit the choices for 'parent' field to those which make sense
|
||||
"""
|
||||
|
||||
form = super(AjaxUpdateView, self).get_form()
|
||||
|
||||
category = self.get_object()
|
||||
|
||||
# Remove any invalid choices for the parent category part
|
||||
parent_choices = PartCategory.objects.all()
|
||||
parent_choices = parent_choices.exclude(id__in=category.getUniqueChildren())
|
||||
|
||||
form.fields['parent'].queryset = parent_choices
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class CategoryDelete(AjaxDeleteView):
|
||||
"""
|
||||
Delete view to delete a PartCategory
|
||||
|
@ -92,13 +92,8 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
|
||||
class StockItemSerialize(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for serializing a stock item
|
||||
"""
|
||||
|
||||
queryset = StockItem.objects.none()
|
||||
serializer_class = StockSerializers.SerializeStockItemSerializer
|
||||
class StockItemContextMixin:
|
||||
""" Mixin class for adding StockItem object to serializer context """
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
@ -113,7 +108,16 @@ class StockItemSerialize(generics.CreateAPIView):
|
||||
return context
|
||||
|
||||
|
||||
class StockItemInstall(generics.CreateAPIView):
|
||||
class StockItemSerialize(StockItemContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for serializing a stock item
|
||||
"""
|
||||
|
||||
queryset = StockItem.objects.none()
|
||||
serializer_class = StockSerializers.SerializeStockItemSerializer
|
||||
|
||||
|
||||
class StockItemInstall(StockItemContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for installing a particular stock item into this stock item.
|
||||
|
||||
@ -125,17 +129,14 @@ class StockItemInstall(generics.CreateAPIView):
|
||||
queryset = StockItem.objects.none()
|
||||
serializer_class = StockSerializers.InstallStockItemSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
context = super().get_serializer_context()
|
||||
context['request'] = self.request
|
||||
class StockItemUninstall(StockItemContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for removing (uninstalling) items from this item
|
||||
"""
|
||||
|
||||
try:
|
||||
context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
return context
|
||||
queryset = StockItem.objects.none()
|
||||
serializer_class = StockSerializers.UninstallStockItemSerializer
|
||||
|
||||
|
||||
class StockAdjustView(generics.CreateAPIView):
|
||||
@ -1421,6 +1422,7 @@ stock_api_urls = [
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
|
||||
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
|
||||
re_path(r'^uninstall/', StockItemUninstall.as_view(), name='api-stock-item-uninstall'),
|
||||
re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
|
||||
])),
|
||||
|
||||
|
@ -5,17 +5,9 @@ Django Forms for interacting with Stock app
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from mptt.fields import TreeNodeChoiceField
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.fields import DatePickerFormField
|
||||
|
||||
from .models import StockLocation, StockItem, StockItemTracking
|
||||
from .models import StockItem, StockItemTracking
|
||||
|
||||
|
||||
class ReturnStockItemForm(HelperForm):
|
||||
@ -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):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Form for creating / editing a StockItemTracking object.
|
||||
|
@ -1142,7 +1142,7 @@ class StockItem(MPTTModel):
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def uninstallIntoLocation(self, location, user, notes):
|
||||
def uninstall_into_location(self, location, user, notes):
|
||||
"""
|
||||
Uninstall this stock item from another item, into a location.
|
||||
|
||||
|
@ -448,6 +448,48 @@ class InstallStockItemSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class UninstallStockItemSerializer(serializers.Serializer):
|
||||
"""
|
||||
API serializers for uninstalling an installed item from a stock item
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'location',
|
||||
'note',
|
||||
]
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False, required=True, allow_null=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Destination location for uninstalled item')
|
||||
)
|
||||
|
||||
note = serializers.CharField(
|
||||
label=_('Notes'),
|
||||
help_text=_('Add transaction note (optional)'),
|
||||
required=False, allow_blank=True,
|
||||
)
|
||||
|
||||
def save(self):
|
||||
|
||||
item = self.context['item']
|
||||
|
||||
data = self.validated_data
|
||||
request = self.context['request']
|
||||
|
||||
location = data['location']
|
||||
|
||||
note = data.get('note', '')
|
||||
|
||||
item.uninstall_into_location(
|
||||
location,
|
||||
request.user,
|
||||
note
|
||||
)
|
||||
|
||||
|
||||
class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for a simple tree view
|
||||
|
@ -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 %}
|
@ -159,9 +159,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@ -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() {
|
||||
setupNotesField(
|
||||
'stock-notes',
|
||||
|
@ -449,12 +449,9 @@ $('#stock-install-in').click(function() {
|
||||
|
||||
$('#stock-uninstall').click(function() {
|
||||
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-uninstall' %}",
|
||||
uninstallStockItem(
|
||||
{{ item.pk }},
|
||||
{
|
||||
data: {
|
||||
'items[]': [{{ item.pk }}],
|
||||
},
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
|
@ -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 %}
|
@ -29,6 +29,7 @@ class StockAPITestCase(InvenTreeAPITestCase):
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'bom',
|
||||
'company',
|
||||
'location',
|
||||
'supplier_part',
|
||||
@ -643,6 +644,88 @@ class StockItemTest(StockAPITestCase):
|
||||
data = self.get(url).data
|
||||
self.assertEqual(data['purchase_price_currency'], 'NZD')
|
||||
|
||||
def test_install(self):
|
||||
""" Test that stock item can be installed into antoher item, via the API """
|
||||
|
||||
# Select the "parent" stock item
|
||||
parent_part = part.models.Part.objects.get(pk=100)
|
||||
|
||||
item = StockItem.objects.create(
|
||||
part=parent_part,
|
||||
serial='12345688-1230',
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
sub_part = part.models.Part.objects.get(pk=50)
|
||||
sub_item = StockItem.objects.create(
|
||||
part=sub_part,
|
||||
serial='xyz-123',
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
n_entries = sub_item.tracking_info.count()
|
||||
|
||||
self.assertIsNone(sub_item.belongs_to)
|
||||
|
||||
url = reverse('api-stock-item-install', kwargs={'pk': item.pk})
|
||||
|
||||
# Try to install an item that is *not* in the BOM for this part!
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'stock_item': 520,
|
||||
'note': 'This should fail, as Item #522 is not in the BOM',
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Selected part is not in the Bill of Materials', str(response.data))
|
||||
|
||||
# Now, try to install an item which *is* in the BOM for the parent part
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'stock_item': sub_item.pk,
|
||||
'note': "This time, it should be good!",
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
sub_item.refresh_from_db()
|
||||
|
||||
self.assertEqual(sub_item.belongs_to, item)
|
||||
|
||||
self.assertEqual(n_entries + 1, sub_item.tracking_info.count())
|
||||
|
||||
# Try to install again - this time, should fail because the StockItem is not available!
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'stock_item': sub_item.pk,
|
||||
'note': 'Expectation: failure!',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Stock item is unavailable', str(response.data))
|
||||
|
||||
# Now, try to uninstall via the API
|
||||
|
||||
url = reverse('api-stock-item-uninstall', kwargs={'pk': sub_item.pk})
|
||||
|
||||
self.post(
|
||||
url,
|
||||
{
|
||||
'location': 1,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
sub_item.refresh_from_db()
|
||||
|
||||
self.assertIsNone(sub_item.belongs_to)
|
||||
self.assertEqual(sub_item.location.pk, 1)
|
||||
|
||||
|
||||
class StocktakeTest(StockAPITestCase):
|
||||
"""
|
||||
|
@ -43,8 +43,6 @@ stock_urls = [
|
||||
# Stock location
|
||||
re_path(r'^location/', include(location_urls)),
|
||||
|
||||
re_path(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'),
|
||||
|
||||
re_path(r'^track/', include(stock_tracking_urls)),
|
||||
|
||||
# Individual stock items
|
||||
|
@ -5,39 +5,24 @@ Django views for interacting with Stock app
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.views.generic.edit import FormMixin
|
||||
from datetime import datetime
|
||||
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.forms.models import model_to_dict
|
||||
from django.forms import HiddenInput
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
from InvenTree.views import AjaxView
|
||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
|
||||
from InvenTree.views import QRCodeView
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from InvenTree.forms import ConfirmForm
|
||||
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.helpers import extract_serial_numbers
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from company.models import SupplierPart
|
||||
from part.models import Part
|
||||
from .models import StockItem, StockLocation, StockItemTracking
|
||||
|
||||
import common.settings
|
||||
from common.models import InvenTreeSetting
|
||||
from users.models import Owner
|
||||
|
||||
from . import forms as StockForms
|
||||
|
||||
@ -135,139 +120,6 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class StockLocationEdit(AjaxUpdateView):
|
||||
"""
|
||||
View for editing details of a StockLocation.
|
||||
This view is used with the EditStockLocationForm to deliver a modal form to the web view
|
||||
|
||||
TODO: Remove this code as location editing has been migrated to the API forms
|
||||
- Have to still validate that all form functionality (as below) as been ported
|
||||
|
||||
"""
|
||||
|
||||
model = StockLocation
|
||||
form_class = StockForms.EditStockLocationForm
|
||||
context_object_name = 'location'
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Edit Stock Location')
|
||||
|
||||
def get_form(self):
|
||||
""" Customize form data for StockLocation editing.
|
||||
|
||||
Limit the choices for 'parent' field to those which make sense.
|
||||
If ownership control is enabled and location has parent, disable owner field.
|
||||
"""
|
||||
|
||||
form = super(AjaxUpdateView, self).get_form()
|
||||
|
||||
location = self.get_object()
|
||||
|
||||
# Remove any invalid choices for the 'parent' field
|
||||
parent_choices = StockLocation.objects.all()
|
||||
parent_choices = parent_choices.exclude(id__in=location.getUniqueChildren())
|
||||
|
||||
form.fields['parent'].queryset = parent_choices
|
||||
|
||||
# Is ownership control enabled?
|
||||
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||
|
||||
if not stock_ownership_control:
|
||||
# Hide owner field
|
||||
form.fields['owner'].widget = HiddenInput()
|
||||
else:
|
||||
# Get location's owner
|
||||
location_owner = location.owner
|
||||
|
||||
if location_owner:
|
||||
if location.parent:
|
||||
try:
|
||||
# If location has parent and owner: automatically select parent's owner
|
||||
parent_owner = location.parent.owner
|
||||
form.fields['owner'].initial = parent_owner
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
# If current owner exists: automatically select it
|
||||
form.fields['owner'].initial = location_owner
|
||||
|
||||
# Update queryset or disable field (only if not admin)
|
||||
if not self.request.user.is_superuser:
|
||||
if type(location_owner.owner) is Group:
|
||||
user_as_owner = Owner.get_owner(self.request.user)
|
||||
queryset = location_owner.get_related_owners(include_group=True)
|
||||
|
||||
if user_as_owner not in queryset:
|
||||
# Only owners or admin can change current owner
|
||||
form.fields['owner'].disabled = True
|
||||
else:
|
||||
form.fields['owner'].queryset = queryset
|
||||
|
||||
return form
|
||||
|
||||
def save(self, object, form, **kwargs):
|
||||
""" If location has children and ownership control is enabled:
|
||||
- update owner of all children location of this location
|
||||
- update owner for all stock items at this location
|
||||
"""
|
||||
|
||||
self.object = form.save()
|
||||
|
||||
# Is ownership control enabled?
|
||||
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||
|
||||
if stock_ownership_control and self.object.owner:
|
||||
# Get authorized users
|
||||
authorized_owners = self.object.owner.get_related_owners()
|
||||
|
||||
# Update children locations
|
||||
children_locations = self.object.get_children()
|
||||
for child in children_locations:
|
||||
# Check if current owner is subset of new owner
|
||||
if child.owner and authorized_owners:
|
||||
if child.owner in authorized_owners:
|
||||
continue
|
||||
|
||||
child.owner = self.object.owner
|
||||
child.save()
|
||||
|
||||
# Update stock items
|
||||
stock_items = self.object.get_stock_items()
|
||||
|
||||
for stock_item in stock_items:
|
||||
# Check if current owner is subset of new owner
|
||||
if stock_item.owner and authorized_owners:
|
||||
if stock_item.owner in authorized_owners:
|
||||
continue
|
||||
|
||||
stock_item.owner = self.object.owner
|
||||
stock_item.save()
|
||||
|
||||
return self.object
|
||||
|
||||
def validate(self, item, form):
|
||||
""" Check that owner is set if stock ownership control is enabled """
|
||||
|
||||
parent = form.cleaned_data.get('parent', None)
|
||||
|
||||
owner = form.cleaned_data.get('owner', None)
|
||||
|
||||
# Is ownership control enabled?
|
||||
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||
|
||||
if stock_ownership_control:
|
||||
if not owner and not self.request.user.is_superuser:
|
||||
form.add_error('owner', _('Owner is required (ownership control is enabled)'))
|
||||
else:
|
||||
try:
|
||||
if parent.owner:
|
||||
if parent.owner != owner:
|
||||
error = f'Owner requires to be equivalent to parent\'s owner ({parent.owner})'
|
||||
form.add_error('owner', error)
|
||||
except AttributeError:
|
||||
# No parent
|
||||
pass
|
||||
|
||||
|
||||
class StockLocationQRCode(QRCodeView):
|
||||
""" View for displaying a QR code for a StockLocation object """
|
||||
|
||||
@ -366,261 +218,6 @@ class StockItemQRCode(QRCodeView):
|
||||
return None
|
||||
|
||||
|
||||
class StockItemUninstall(AjaxView, FormMixin):
|
||||
"""
|
||||
View for uninstalling one or more StockItems,
|
||||
which are installed in another stock item.
|
||||
|
||||
Stock items are uninstalled into a location,
|
||||
defaulting to the location that they were "in" before they were installed.
|
||||
|
||||
If multiple default locations are detected,
|
||||
leave the final location up to the user.
|
||||
"""
|
||||
|
||||
ajax_template_name = 'stock/stock_uninstall.html'
|
||||
ajax_form_title = _('Uninstall Stock Items')
|
||||
form_class = StockForms.UninstallStockForm
|
||||
role_required = 'stock.change'
|
||||
|
||||
# List of stock items to uninstall (initially empty)
|
||||
stock_items = []
|
||||
|
||||
def get_stock_items(self):
|
||||
|
||||
return self.stock_items
|
||||
|
||||
def get_initial(self):
|
||||
|
||||
initials = super().get_initial().copy()
|
||||
|
||||
# Keep track of the current locations of stock items
|
||||
current_locations = set()
|
||||
|
||||
# Keep track of the default locations for stock items
|
||||
default_locations = set()
|
||||
|
||||
for item in self.stock_items:
|
||||
|
||||
if item.location:
|
||||
current_locations.add(item.location)
|
||||
|
||||
if item.part.default_location:
|
||||
default_locations.add(item.part.default_location)
|
||||
|
||||
if len(current_locations) == 1:
|
||||
# If the selected stock items are currently in a single location,
|
||||
# select that location as the destination.
|
||||
initials['location'] = next(iter(current_locations))
|
||||
elif len(current_locations) == 0:
|
||||
# There are no current locations set
|
||||
if len(default_locations) == 1:
|
||||
# Select the single default location
|
||||
initials['location'] = next(iter(default_locations))
|
||||
|
||||
return initials
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
""" Extract list of stock items, which are supplied as a list,
|
||||
e.g. items[]=1,2,3
|
||||
"""
|
||||
|
||||
if 'items[]' in request.GET:
|
||||
self.stock_items = StockItem.objects.filter(id__in=request.GET.getlist('items[]'))
|
||||
else:
|
||||
self.stock_items = []
|
||||
|
||||
return self.renderJsonResponse(request, self.get_form())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
"""
|
||||
Extract a list of stock items which are included as hidden inputs in the form data.
|
||||
"""
|
||||
|
||||
items = []
|
||||
|
||||
for item in self.request.POST:
|
||||
if item.startswith('stock-item-'):
|
||||
pk = item.replace('stock-item-', '')
|
||||
|
||||
try:
|
||||
stock_item = StockItem.objects.get(pk=pk)
|
||||
items.append(stock_item)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
self.stock_items = items
|
||||
|
||||
# Assume the form is valid, until it isn't!
|
||||
valid = True
|
||||
|
||||
confirmed = str2bool(request.POST.get('confirm'))
|
||||
|
||||
note = request.POST.get('note', '')
|
||||
|
||||
location = request.POST.get('location', None)
|
||||
|
||||
if location:
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=location)
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
location = None
|
||||
|
||||
if not location:
|
||||
# Location is required!
|
||||
valid = False
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
if not confirmed:
|
||||
valid = False
|
||||
form.add_error('confirm', _('Confirm stock adjustment'))
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
}
|
||||
|
||||
if valid:
|
||||
# Ok, now let's actually uninstall the stock items
|
||||
for item in self.stock_items:
|
||||
item.uninstallIntoLocation(location, request.user, note)
|
||||
|
||||
data['success'] = _('Uninstalled stock items')
|
||||
|
||||
return self.renderJsonResponse(request, form=form, data=data)
|
||||
|
||||
def get_context_data(self):
|
||||
|
||||
context = super().get_context_data()
|
||||
|
||||
context['stock_items'] = self.get_stock_items()
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class StockItemEdit(AjaxUpdateView):
|
||||
"""
|
||||
View for editing details of a single StockItem
|
||||
"""
|
||||
|
||||
model = StockItem
|
||||
form_class = StockForms.EditStockItemForm
|
||||
context_object_name = 'item'
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Edit Stock Item')
|
||||
|
||||
def get_form(self):
|
||||
""" Get form for StockItem editing.
|
||||
|
||||
Limit the choices for supplier_part
|
||||
"""
|
||||
|
||||
form = super(AjaxUpdateView, self).get_form()
|
||||
|
||||
# Hide the "expiry date" field if the feature is not enabled
|
||||
if not common.settings.stock_expiry_enabled():
|
||||
form.fields['expiry_date'].widget = HiddenInput()
|
||||
|
||||
item = self.get_object()
|
||||
|
||||
# If the part cannot be purchased, hide the supplier_part field
|
||||
if not item.part.purchaseable:
|
||||
form.fields['supplier_part'].widget = HiddenInput()
|
||||
|
||||
form.fields.pop('purchase_price')
|
||||
else:
|
||||
query = form.fields['supplier_part'].queryset
|
||||
query = query.filter(part=item.part.id)
|
||||
form.fields['supplier_part'].queryset = query
|
||||
|
||||
# Hide the serial number field if it is not required
|
||||
if not item.part.trackable and not item.serialized:
|
||||
form.fields['serial'].widget = HiddenInput()
|
||||
|
||||
location = item.location
|
||||
|
||||
# Is ownership control enabled?
|
||||
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||
|
||||
if not stock_ownership_control:
|
||||
form.fields['owner'].widget = HiddenInput()
|
||||
else:
|
||||
try:
|
||||
location_owner = location.owner
|
||||
except AttributeError:
|
||||
location_owner = None
|
||||
|
||||
# Check if location has owner
|
||||
if location_owner:
|
||||
form.fields['owner'].initial = location_owner
|
||||
|
||||
# Check location's owner type and filter potential owners
|
||||
if type(location_owner.owner) is Group:
|
||||
user_as_owner = Owner.get_owner(self.request.user)
|
||||
queryset = location_owner.get_related_owners(include_group=True)
|
||||
|
||||
if user_as_owner in queryset:
|
||||
form.fields['owner'].initial = user_as_owner
|
||||
|
||||
form.fields['owner'].queryset = queryset
|
||||
|
||||
elif type(location_owner.owner) is get_user_model():
|
||||
# If location's owner is a user: automatically set owner field and disable it
|
||||
form.fields['owner'].disabled = True
|
||||
form.fields['owner'].initial = location_owner
|
||||
|
||||
try:
|
||||
item_owner = item.owner
|
||||
except AttributeError:
|
||||
item_owner = None
|
||||
|
||||
# Check if item has owner
|
||||
if item_owner:
|
||||
form.fields['owner'].initial = item_owner
|
||||
|
||||
# Check item's owner type and filter potential owners
|
||||
if type(item_owner.owner) is Group:
|
||||
user_as_owner = Owner.get_owner(self.request.user)
|
||||
queryset = item_owner.get_related_owners(include_group=True)
|
||||
|
||||
if user_as_owner in queryset:
|
||||
form.fields['owner'].initial = user_as_owner
|
||||
|
||||
form.fields['owner'].queryset = queryset
|
||||
|
||||
elif type(item_owner.owner) is get_user_model():
|
||||
# If item's owner is a user: automatically set owner field and disable it
|
||||
form.fields['owner'].disabled = True
|
||||
form.fields['owner'].initial = item_owner
|
||||
|
||||
return form
|
||||
|
||||
def validate(self, item, form):
|
||||
""" Check that owner is set if stock ownership control is enabled """
|
||||
|
||||
owner = form.cleaned_data.get('owner', None)
|
||||
|
||||
# Is ownership control enabled?
|
||||
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||
|
||||
if stock_ownership_control:
|
||||
if not owner and not self.request.user.is_superuser:
|
||||
form.add_error('owner', _('Owner is required (ownership control is enabled)'))
|
||||
|
||||
def save(self, object, form, **kwargs):
|
||||
"""
|
||||
Override the save method, to track the user who updated the model
|
||||
"""
|
||||
|
||||
item = form.save(commit=False)
|
||||
|
||||
item.save(user=self.request.user)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
class StockItemConvert(AjaxUpdateView):
|
||||
"""
|
||||
View for 'converting' a StockItem to a variant of its current part.
|
||||
@ -655,435 +252,6 @@ class StockItemConvert(AjaxUpdateView):
|
||||
return stock_item
|
||||
|
||||
|
||||
class StockLocationCreate(AjaxCreateView):
|
||||
"""
|
||||
View for creating a new StockLocation
|
||||
A parent location (another StockLocation object) can be passed as a query parameter
|
||||
|
||||
TODO: Remove this class entirely, as it has been migrated to the API forms
|
||||
- Still need to check that all the functionality (as below) has been implemented
|
||||
|
||||
"""
|
||||
|
||||
model = StockLocation
|
||||
form_class = StockForms.EditStockLocationForm
|
||||
context_object_name = 'location'
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Create new Stock Location')
|
||||
|
||||
def get_initial(self):
|
||||
initials = super(StockLocationCreate, self).get_initial().copy()
|
||||
|
||||
loc_id = self.request.GET.get('location', None)
|
||||
|
||||
if loc_id:
|
||||
try:
|
||||
initials['parent'] = StockLocation.objects.get(pk=loc_id)
|
||||
except StockLocation.DoesNotExist:
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
def get_form(self):
|
||||
""" Disable owner field when:
|
||||
- creating child location
|
||||
- and stock ownership control is enable
|
||||
"""
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
# Is ownership control enabled?
|
||||
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||
|
||||
if not stock_ownership_control:
|
||||
# Hide owner field
|
||||
form.fields['owner'].widget = HiddenInput()
|
||||
else:
|
||||
# If user did not selected owner: automatically match to parent's owner
|
||||
if not form['owner'].data:
|
||||
try:
|
||||
parent_id = form['parent'].value()
|
||||
parent = StockLocation.objects.get(pk=parent_id)
|
||||
|
||||
if parent:
|
||||
form.fields['owner'].initial = parent.owner
|
||||
if not self.request.user.is_superuser:
|
||||
form.fields['owner'].disabled = True
|
||||
except StockLocation.DoesNotExist:
|
||||
pass
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return form
|
||||
|
||||
def save(self, form):
|
||||
""" If parent location exists then use it to set the owner """
|
||||
|
||||
self.object = form.save(commit=False)
|
||||
|
||||
parent = form.cleaned_data.get('parent', None)
|
||||
|
||||
if parent:
|
||||
# Select parent's owner
|
||||
self.object.owner = parent.owner
|
||||
|
||||
self.object.save()
|
||||
|
||||
return self.object
|
||||
|
||||
def validate(self, item, form):
|
||||
""" Check that owner is set if stock ownership control is enabled """
|
||||
|
||||
parent = form.cleaned_data.get('parent', None)
|
||||
|
||||
owner = form.cleaned_data.get('owner', None)
|
||||
|
||||
# Is ownership control enabled?
|
||||
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
|
||||
|
||||
if stock_ownership_control:
|
||||
if not owner and not self.request.user.is_superuser:
|
||||
form.add_error('owner', _('Owner is required (ownership control is enabled)'))
|
||||
else:
|
||||
try:
|
||||
if parent.owner:
|
||||
if parent.owner != owner:
|
||||
error = f'Owner requires to be equivalent to parent\'s owner ({parent.owner})'
|
||||
form.add_error('owner', error)
|
||||
except AttributeError:
|
||||
# No parent
|
||||
pass
|
||||
|
||||
|
||||
class StockItemCreate(AjaxCreateView):
|
||||
"""
|
||||
View for creating a new StockItem
|
||||
Parameters can be pre-filled by passing query items:
|
||||
- part: The part of which the new StockItem is an instance
|
||||
- location: The location of the new StockItem
|
||||
|
||||
If the parent part is a "tracked" part, provide an option to create uniquely serialized items
|
||||
rather than a bulk quantity of stock items
|
||||
"""
|
||||
|
||||
model = StockItem
|
||||
form_class = StockForms.CreateStockItemForm
|
||||
context_object_name = 'item'
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Create new Stock Item')
|
||||
|
||||
def get_part(self, form=None):
|
||||
"""
|
||||
Attempt to get the "part" associted with this new stockitem.
|
||||
|
||||
- May be passed to the form as a query parameter (e.g. ?part=<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):
|
||||
"""
|
||||
View to delete a StockLocation
|
||||
|
@ -21,6 +21,7 @@
|
||||
/* exported
|
||||
allocateStockToBuild,
|
||||
autoAllocateStockToBuild,
|
||||
cancelBuildOrder,
|
||||
completeBuildOrder,
|
||||
createBuildOutput,
|
||||
editBuildOrder,
|
||||
@ -123,6 +124,49 @@ function newBuildOrder(options={}) {
|
||||
}
|
||||
|
||||
|
||||
/* Construct a form to cancel a build order */
|
||||
function cancelBuildOrder(build_id, options={}) {
|
||||
|
||||
constructForm(
|
||||
`/api/build/${build_id}/cancel/`,
|
||||
{
|
||||
method: 'POST',
|
||||
title: '{% trans "Cancel Build Order" %}',
|
||||
confirm: true,
|
||||
fields: {
|
||||
remove_allocated_stock: {},
|
||||
remove_incomplete_outputs: {},
|
||||
},
|
||||
preFormContent: function(opts) {
|
||||
var html = `
|
||||
<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 */
|
||||
function completeBuildOrder(build_id, options={}) {
|
||||
|
||||
|
@ -123,6 +123,9 @@ function getApiEndpointOptions(url, callback) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Include extra context information in the request
|
||||
url += '?context=true';
|
||||
|
||||
// Return the ajax request object
|
||||
$.ajax({
|
||||
url: url,
|
||||
@ -335,6 +338,9 @@ function constructForm(url, options) {
|
||||
// Request OPTIONS endpoint from the API
|
||||
getApiEndpointOptions(url, function(OPTIONS) {
|
||||
|
||||
// Extract any custom 'context' information from the OPTIONS data
|
||||
options.context = OPTIONS.context || {};
|
||||
|
||||
/*
|
||||
* Determine what "type" of form we want to construct,
|
||||
* based on the requested action.
|
||||
@ -527,7 +533,14 @@ function constructFormBody(fields, options) {
|
||||
$(modal).find('#form-content').html(html);
|
||||
|
||||
if (options.preFormContent) {
|
||||
$(modal).find('#pre-form-content').html(options.preFormContent);
|
||||
|
||||
if (typeof(options.preFormContent) === 'function') {
|
||||
var content = options.preFormContent(options);
|
||||
} else {
|
||||
var content = options.preFormContent;
|
||||
}
|
||||
|
||||
$(modal).find('#pre-form-content').html(content);
|
||||
}
|
||||
|
||||
if (options.postFormContent) {
|
||||
|
@ -81,7 +81,7 @@ function renderStockItem(name, data, parameters={}, options={}) {
|
||||
|
||||
var part_detail = '';
|
||||
|
||||
if (render_part_detail) {
|
||||
if (render_part_detail && data.part_detail) {
|
||||
part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `;
|
||||
}
|
||||
|
||||
|
@ -20,11 +20,15 @@
|
||||
|
||||
/* exported
|
||||
allocateStockToSalesOrder,
|
||||
cancelPurchaseOrder,
|
||||
cancelSalesOrder,
|
||||
completePurchaseOrder,
|
||||
completeShipment,
|
||||
createSalesOrder,
|
||||
createSalesOrderShipment,
|
||||
editPurchaseOrderLineItem,
|
||||
exportOrder,
|
||||
issuePurchaseOrder,
|
||||
loadPurchaseOrderLineItemTable,
|
||||
loadPurchaseOrderExtraLineTable
|
||||
loadPurchaseOrderTable,
|
||||
@ -140,6 +144,133 @@ function completeShipment(shipment_id) {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Launches a modal form to mark a PurchaseOrder as "complete"
|
||||
*/
|
||||
function completePurchaseOrder(order_id, options={}) {
|
||||
|
||||
constructForm(
|
||||
`/api/order/po/${order_id}/complete/`,
|
||||
{
|
||||
method: 'POST',
|
||||
title: '{% trans "Complete Purchase Order" %}',
|
||||
confirm: true,
|
||||
preFormContent: function(opts) {
|
||||
|
||||
var html = `
|
||||
<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
|
||||
function createSalesOrderShipment(options={}) {
|
||||
|
@ -57,6 +57,7 @@
|
||||
stockItemFields,
|
||||
stockLocationFields,
|
||||
stockStatusCodes,
|
||||
uninstallStockItem,
|
||||
*/
|
||||
|
||||
|
||||
@ -2630,13 +2631,10 @@ function loadInstalledInTable(table, options) {
|
||||
table.find('.button-uninstall').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(
|
||||
'{% url "stock-item-uninstall" %}',
|
||||
uninstallStockItem(
|
||||
pk,
|
||||
{
|
||||
data: {
|
||||
'items[]': pk,
|
||||
},
|
||||
success: function() {
|
||||
onSuccess: function(response) {
|
||||
table.bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
@ -2647,6 +2645,43 @@ function loadInstalledInTable(table, options) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Launch a dialog to uninstall a stock item from another stock item
|
||||
*/
|
||||
function uninstallStockItem(installed_item_id, options={}) {
|
||||
|
||||
constructForm(
|
||||
`/api/stock/${installed_item_id}/uninstall/`,
|
||||
{
|
||||
confirm: true,
|
||||
method: 'POST',
|
||||
title: '{% trans "Uninstall Stock Item" %}',
|
||||
fields: {
|
||||
location: {
|
||||
icon: 'fa-sitemap',
|
||||
},
|
||||
note: {},
|
||||
},
|
||||
preFormContent: function(opts) {
|
||||
var html = '';
|
||||
|
||||
if (installed_item_id == null) {
|
||||
html += `
|
||||
<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
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user