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