diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index e2bdae1d8f..7af7c82914 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -141,3 +141,15 @@ class InvenTreeAPITestCase(APITestCase): self.assertEqual(response.status_code, expected_code) return response + + def options(self, url, expected_code=None): + """ + Issue an OPTIONS request + """ + + response = self.client.options(url, format='json') + + if expected_code is not None: + self.assertEqual(response.status_code, expected_code) + + return response diff --git a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py new file mode 100644 index 0000000000..07e700a1cf --- /dev/null +++ b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py @@ -0,0 +1,70 @@ +""" +Custom management command to rebuild thumbnail images + +- May be required after importing a new dataset, for example +""" + +import os +import logging + +from PIL import UnidentifiedImageError + +from django.core.management.base import BaseCommand +from django.conf import settings +from django.db.utils import OperationalError, ProgrammingError + +from company.models import Company +from part.models import Part + + +logger = logging.getLogger("inventree-thumbnails") + + +class Command(BaseCommand): + """ + Rebuild all thumbnail images + """ + + def rebuild_thumbnail(self, model): + """ + Rebuild the thumbnail specified by the "image" field of the provided model + """ + + if not model.image: + return + + img = model.image + url = img.thumbnail.name + loc = os.path.join(settings.MEDIA_ROOT, url) + + if not os.path.exists(loc): + logger.info(f"Generating thumbnail image for '{img}'") + + try: + model.image.render_variations(replace=False) + except FileNotFoundError: + logger.error(f"ERROR: Image file '{img}' is missing") + except UnidentifiedImageError: + logger.error(f"ERROR: Image file '{img}' is not a valid image") + + def handle(self, *args, **kwargs): + + logger.setLevel(logging.INFO) + + logger.info("Rebuilding Part thumbnails") + + for part in Part.objects.exclude(image=None): + try: + self.rebuild_thumbnail(part) + except (OperationalError, ProgrammingError): + logger.error("ERROR: Database read error.") + break + + logger.info("Rebuilding Company thumbnails") + + for company in Company.objects.exclude(image=None): + try: + self.rebuild_thumbnail(company) + except (OperationalError, ProgrammingError): + logger.error("ERROR: abase read error.") + break diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index f309f85e66..03ee877cb2 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,11 +10,19 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" -INVENTREE_API_VERSION = 12 +INVENTREE_API_VERSION = 14 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v14 -> 2021-20-05 + - Stock adjustment actions API is improved, using native DRF serializer support + - However adjustment actions now only support 'pk' as a lookup field + +v13 -> 2021-10-05 + - Adds API endpoint to allocate stock items against a BuildOrder + - Updates StockItem API with improved filtering against BomItem data + v12 -> 2021-09-07 - Adds API endpoint to receive stock items against a PurchaseOrder diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index eb6d42cc6d..cc897d6ec9 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -5,10 +5,12 @@ JSON API for the Build app # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.utils.translation import ugettext_lazy as _ + from django.conf.urls import url, include -from rest_framework import filters -from rest_framework import generics +from rest_framework import filters, generics +from rest_framework.serializers import ValidationError from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as rest_filters @@ -19,6 +21,7 @@ from InvenTree.status_codes import BuildStatus from .models import Build, BuildItem, BuildOrderAttachment from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer +from .serializers import BuildAllocationSerializer class BuildFilter(rest_filters.FilterSet): @@ -92,7 +95,7 @@ class BuildList(generics.ListCreateAPIView): as some of the fields don't natively play nicely with DRF """ - queryset = super().get_queryset().prefetch_related('part') + queryset = super().get_queryset().select_related('part') queryset = BuildSerializer.annotate_queryset(queryset) @@ -181,6 +184,58 @@ class BuildDetail(generics.RetrieveUpdateAPIView): serializer_class = BuildSerializer +class BuildAllocate(generics.CreateAPIView): + """ + API endpoint to allocate stock items to a build order + + - The BuildOrder object is specified by the URL + - Items to allocate are specified as a list called "items" with the following options: + - bom_item: pk value of a given BomItem object (must match the part associated with this build) + - stock_item: pk value of a given StockItem object + - quantity: quantity to allocate + - output: StockItem (build order output) to allocate stock against (optional) + """ + + queryset = Build.objects.none() + + serializer_class = BuildAllocationSerializer + + def get_build(self): + """ + Returns the BuildOrder associated with this API endpoint + """ + + pk = self.kwargs.get('pk', None) + + try: + build = Build.objects.get(pk=pk) + except (Build.DoesNotExist, ValueError): + raise ValidationError(_("Matching build order does not exist")) + + return build + + def get_serializer_context(self): + """ + Provide the Build object to the serializer context + """ + + context = super().get_serializer_context() + + context['build'] = self.get_build() + context['request'] = self.request + + return context + + +class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for detail view of a BuildItem object + """ + + queryset = BuildItem.objects.all() + serializer_class = BuildItemSerializer + + class BuildItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of BuildItem objects @@ -210,9 +265,9 @@ class BuildItemList(generics.ListCreateAPIView): query = BuildItem.objects.all() - query = query.select_related('stock_item') - query = query.prefetch_related('stock_item__part') - query = query.prefetch_related('stock_item__part__category') + query = query.select_related('stock_item__location') + query = query.select_related('stock_item__part') + query = query.select_related('stock_item__part__category') return query @@ -282,16 +337,20 @@ build_api_urls = [ # Attachments url(r'^attachment/', include([ url(r'^(?P\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'), - url('^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), + url(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), ])), # Build Items url(r'^item/', include([ - url('^.*$', BuildItemList.as_view(), name='api-build-item-list') + url(r'^(?P\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'), + url(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'), ])), # Build Detail - url(r'^(?P\d+)/', BuildDetail.as_view(), name='api-build-detail'), + url(r'^(?P\d+)/', include([ + url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), + ])), # Build List url(r'^.*$', BuildList.as_view(), name='api-build-list'), diff --git a/InvenTree/build/fixtures/build.yaml b/InvenTree/build/fixtures/build.yaml index cc645f9696..1506c9402a 100644 --- a/InvenTree/build/fixtures/build.yaml +++ b/InvenTree/build/fixtures/build.yaml @@ -3,7 +3,7 @@ - model: build.build pk: 1 fields: - part: 25 + part: 100 # Build against part 100 "Bob" batch: 'B1' reference: "0001" title: 'Building 7 parts' diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index e2ca7c3f75..b3f6cd92de 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -15,7 +15,7 @@ from InvenTree.fields import DatePickerFormField from InvenTree.status_codes import StockStatus -from .models import Build, BuildItem +from .models import Build from stock.models import StockLocation, StockItem @@ -163,18 +163,6 @@ class UnallocateBuildForm(HelperForm): ] -class AutoAllocateForm(HelperForm): - """ Form for auto-allocation of stock to a build """ - - confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation')) - - class Meta: - model = Build - fields = [ - 'confirm', - ] - - class CompleteBuildForm(HelperForm): """ Form for marking a build as complete @@ -256,22 +244,3 @@ class CancelBuildForm(HelperForm): fields = [ 'confirm_cancel' ] - - -class EditBuildItemForm(HelperForm): - """ - Form for creating (or editing) a BuildItem object. - """ - - quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'), help_text=_('Select quantity of stock to allocate')) - - part_id = forms.IntegerField(required=False, widget=forms.HiddenInput()) - - class Meta: - model = BuildItem - fields = [ - 'build', - 'stock_item', - 'quantity', - 'install_into', - ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 084f9ab2db..9a7b40b52f 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -4,12 +4,14 @@ Build database model definitions # -*- coding: utf-8 -*- from __future__ import unicode_literals +import decimal import os from datetime import datetime -from django.contrib.auth.models import User from django.utils.translation import ugettext_lazy as _ + +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.urls import reverse @@ -584,86 +586,6 @@ class Build(MPTTModel): self.status = BuildStatus.CANCELLED self.save() - def getAutoAllocations(self): - """ - Return a list of StockItem objects which will be allocated - using the 'AutoAllocate' function. - - For each item in the BOM for the attached Part, - the following tests must *all* evaluate to True, - for the part to be auto-allocated: - - - The sub_item in the BOM line must *not* be trackable - - There is only a single stock item available (which has not already been allocated to this build) - - The stock item has an availability greater than zero - - Returns: - A list object containing the StockItem objects to be allocated (and the quantities). - Each item in the list is a dict as follows: - { - 'stock_item': stock_item, - 'quantity': stock_quantity, - } - """ - - allocations = [] - - """ - Iterate through each item in the BOM - """ - - for bom_item in self.bom_items: - - part = bom_item.sub_part - - # If the part is "trackable" it cannot be auto-allocated - if part.trackable: - continue - - # Skip any parts which are already fully allocated - if self.isPartFullyAllocated(part, None): - continue - - # How many parts are required to complete the output? - required = self.unallocatedQuantity(part, None) - - # Grab a list of stock items which are available - stock_items = self.availableStockItems(part, None) - - # Ensure that the available stock items are in the correct location - if self.take_from is not None: - # Filter for stock that is located downstream of the designated location - stock_items = stock_items.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()]) - - # Only one StockItem to choose from? Default to that one! - if stock_items.count() == 1: - stock_item = stock_items[0] - - # Double check that we have not already allocated this stock-item against this build - build_items = BuildItem.objects.filter( - build=self, - stock_item=stock_item, - ) - - if len(build_items) > 0: - continue - - # How many items are actually available? - if stock_item.quantity > 0: - - # Only take as many as are available - if stock_item.quantity < required: - required = stock_item.quantity - - allocation = { - 'stock_item': stock_item, - 'quantity': required, - } - - allocations.append(allocation) - - return allocations - @transaction.atomic def unallocateOutput(self, output, part=None): """ @@ -803,37 +725,6 @@ class Build(MPTTModel): # Remove the build output from the database output.delete() - @transaction.atomic - def autoAllocate(self): - """ - Run auto-allocation routine to allocate StockItems to this Build. - - Args: - output: If specified, only auto-allocate against the given built output - - Returns a list of dict objects with keys like: - - { - 'stock_item': item, - 'quantity': quantity, - } - - See: getAutoAllocations() - """ - - allocations = self.getAutoAllocations() - - for item in allocations: - # Create a new allocation - build_item = BuildItem( - build=self, - stock_item=item['stock_item'], - quantity=item['quantity'], - install_into=None - ) - - build_item.save() - @transaction.atomic def subtractUntrackedStock(self, user): """ @@ -1165,8 +1056,10 @@ class BuildItem(models.Model): Attributes: build: Link to a Build object + bom_item: Link to a BomItem object (may or may not point to the same part as the build) stock_item: Link to a StockItem object quantity: Number of units allocated + install_into: Destination stock item (or None) """ @staticmethod @@ -1185,35 +1078,13 @@ class BuildItem(models.Model): def save(self, *args, **kwargs): - self.validate_unique() self.clean() super().save() - def validate_unique(self, exclude=None): - """ - Test that this BuildItem object is "unique". - Essentially we do not want a stock_item being allocated to a Build multiple times. - """ - - super().validate_unique(exclude) - - items = BuildItem.objects.exclude(id=self.id).filter( - build=self.build, - stock_item=self.stock_item, - install_into=self.install_into - ) - - if items.exists(): - msg = _("BuildItem must be unique for build, stock_item and install_into") - raise ValidationError({ - 'build': msg, - 'stock_item': msg, - 'install_into': msg - }) - def clean(self): - """ Check validity of the BuildItem model. + """ + Check validity of this BuildItem instance. The following checks are performed: - StockItem.part must be in the BOM of the Part object referenced by Build @@ -1224,8 +1095,6 @@ class BuildItem(models.Model): super().clean() - errors = {} - try: # If the 'part' is trackable, then the 'install_into' field must be set! @@ -1234,29 +1103,39 @@ class BuildItem(models.Model): # Allocated quantity cannot exceed available stock quantity if self.quantity > self.stock_item.quantity: - errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format( - n=normalize(self.quantity), - q=normalize(self.stock_item.quantity) - )] + + q = normalize(self.quantity) + a = normalize(self.stock_item.quantity) + + raise ValidationError({ + 'quantity': _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})') + }) # Allocated quantity cannot cause the stock item to be over-allocated - if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity: - errors['quantity'] = _('StockItem is over-allocated') + available = decimal.Decimal(self.stock_item.quantity) + allocated = decimal.Decimal(self.stock_item.allocation_count()) + quantity = decimal.Decimal(self.quantity) + + if available - allocated + quantity < quantity: + raise ValidationError({ + 'quantity': _('Stock item is over-allocated') + }) # Allocated quantity must be positive if self.quantity <= 0: - errors['quantity'] = _('Allocation quantity must be greater than zero') + raise ValidationError({ + 'quantity': _('Allocation quantity must be greater than zero'), + }) # Quantity must be 1 for serialized stock if self.stock_item.serialized and not self.quantity == 1: - errors['quantity'] = _('Quantity must be 1 for serialized stock') + raise ValidationError({ + 'quantity': _('Quantity must be 1 for serialized stock') + }) except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist): pass - if len(errors) > 0: - raise ValidationError(errors) - """ Attempt to find the "BomItem" which links this BuildItem to the build. @@ -1269,7 +1148,7 @@ class BuildItem(models.Model): """ A BomItem object has already been assigned. This is valid if: - a) It points to the same "part" as the referened build + a) It points to the same "part" as the referenced build b) Either: i) The sub_part points to the same part as the referenced StockItem ii) The BomItem allows variants and the part referenced by the StockItem @@ -1309,7 +1188,7 @@ class BuildItem(models.Model): if not bom_item_valid: raise ValidationError({ - 'stock_item': _("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name) + 'stock_item': _("Selected stock item not found in BOM") }) @transaction.atomic diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 69e3a7aed0..53e71dbd27 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -5,16 +5,25 @@ JSON serializers for Build API # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.db import transaction +from django.core.exceptions import ValidationError as DjangoValidationError +from django.utils.translation import ugettext_lazy as _ + from django.db.models import Case, When, Value from django.db.models import BooleanField from rest_framework import serializers +from rest_framework.serializers import ValidationError from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief -from stock.serializers import StockItemSerializerBrief -from stock.serializers import LocationSerializer +import InvenTree.helpers + +from stock.models import StockItem +from stock.serializers import StockItemSerializerBrief, LocationSerializer + +from part.models import BomItem from part.serializers import PartSerializer, PartBriefSerializer from users.serializers import OwnerSerializer @@ -22,7 +31,9 @@ from .models import Build, BuildItem, BuildOrderAttachment class BuildSerializer(InvenTreeModelSerializer): - """ Serializes a Build object """ + """ + Serializes a Build object + """ url = serializers.CharField(source='get_absolute_url', read_only=True) status_text = serializers.CharField(source='get_status_display', read_only=True) @@ -109,6 +120,170 @@ class BuildSerializer(InvenTreeModelSerializer): ] +class BuildAllocationItemSerializer(serializers.Serializer): + """ + A serializer for allocating a single stock item against a build order + """ + + bom_item = serializers.PrimaryKeyRelatedField( + queryset=BomItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('BOM Item'), + ) + + def validate_bom_item(self, bom_item): + + build = self.context['build'] + + # BomItem must point to the same 'part' as the parent build + if build.part != bom_item.part: + raise ValidationError(_("bom_item.part must point to the same part as the build order")) + + return bom_item + + stock_item = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Stock Item'), + ) + + def validate_stock_item(self, stock_item): + + if not stock_item.in_stock: + raise ValidationError(_("Item must be in stock")) + + return stock_item + + quantity = serializers.DecimalField( + max_digits=15, + decimal_places=5, + min_value=0, + required=True + ) + + def validate_quantity(self, quantity): + + if quantity <= 0: + raise ValidationError(_("Quantity must be greater than zero")) + + return quantity + + output = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.filter(is_building=True), + many=False, + allow_null=True, + required=False, + label=_('Build Output'), + ) + + class Meta: + fields = [ + 'bom_item', + 'stock_item', + 'quantity', + 'output', + ] + + def validate(self, data): + + super().validate(data) + + bom_item = data['bom_item'] + stock_item = data['stock_item'] + quantity = data['quantity'] + output = data.get('output', None) + + # build = self.context['build'] + + # TODO: Check that the "stock item" is valid for the referenced "sub_part" + # Note: Because of allow_variants options, it may not be a direct match! + + # Check that the quantity does not exceed the available amount from the stock item + q = stock_item.unallocated_quantity() + + if quantity > q: + + q = InvenTree.helpers.clean_decimal(q) + + raise ValidationError({ + 'quantity': _(f"Available quantity ({q}) exceeded") + }) + + # Output *must* be set for trackable parts + if output is None and bom_item.sub_part.trackable: + raise ValidationError({ + 'output': _('Build output must be specified for allocation of tracked parts') + }) + + # Output *cannot* be set for un-tracked parts + if output is not None and not bom_item.sub_part.trackable: + + raise ValidationError({ + 'output': _('Build output cannot be specified for allocation of untracked parts') + }) + + return data + + +class BuildAllocationSerializer(serializers.Serializer): + """ + DRF serializer for allocation stock items against a build order + """ + + items = BuildAllocationItemSerializer(many=True) + + class Meta: + fields = [ + 'items', + ] + + def validate(self, data): + """ + Validation + """ + + super().validate(data) + + items = data.get('items', []) + + if len(items) == 0: + raise ValidationError(_('Allocation items must be provided')) + + return data + + def save(self): + + data = self.validated_data + + items = data.get('items', []) + + build = self.context['build'] + + with transaction.atomic(): + for item in items: + bom_item = item['bom_item'] + stock_item = item['stock_item'] + quantity = item['quantity'] + output = item.get('output', None) + + try: + # Create a new BuildItem to allocate stock + BuildItem.objects.create( + build=build, + bom_item=bom_item, + stock_item=stock_item, + quantity=quantity, + install_into=output + ) + except (ValidationError, DjangoValidationError) as exc: + # Catch model errors and re-throw as DRF errors + raise ValidationError(detail=serializers.as_serializer_error(exc)) + + class BuildItemSerializer(InvenTreeModelSerializer): """ Serializes a BuildItem object """ diff --git a/InvenTree/build/templates/build/auto_allocate.html b/InvenTree/build/templates/build/auto_allocate.html deleted file mode 100644 index 2f2c7bbca7..0000000000 --- a/InvenTree/build/templates/build/auto_allocate.html +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} -{% load inventree_extras %} -{% block pre_form_content %} - -{{ block.super }} - -
- {% trans "Automatically Allocate Stock" %}
- {% trans "The following stock items will be allocated to the specified build output" %} -
-{% if allocations %} - - - - - - - -{% for item in allocations %} - - - - - - -{% endfor %} -
{% trans "Part" %}{% trans "Quantity" %}{% trans "Location" %}
- {% include "hover_image.html" with image=item.stock_item.part.image hover=True %} - - {{ item.stock_item.part.full_name }}
- {{ item.stock_item.part.description }} -
{% decimal item.quantity %}{{ item.stock_item.location }}
- -{% else %} -
- {% trans "No stock items found that can be automatically allocated to this build" %} -
- {% trans "Stock items will have to be manually allocated" %} -
-{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/create_build_item.html b/InvenTree/build/templates/build/create_build_item.html deleted file mode 100644 index cc23bd49a9..0000000000 --- a/InvenTree/build/templates/build/create_build_item.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} -
-

- {% trans "Select a stock item to allocate to the selected build output" %} -

- {% if output %} -

- {% blocktrans %}The allocated stock will be installed into the following build output:
{{output}}{% endblocktrans %} -

- {% endif %} -
-{% if no_stock %} - -{% endif %} -{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/delete_build_item.html b/InvenTree/build/templates/build/delete_build_item.html deleted file mode 100644 index d5cc285466..0000000000 --- a/InvenTree/build/templates/build/delete_build_item.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "modal_delete_form.html" %} -{% load i18n %} -{% load inventree_extras %} - -{% block pre_form_content %} -
-

- {% trans "Are you sure you want to unallocate this stock?" %} -

-

- {% trans "The selected stock will be unallocated from the build output" %} -

-
-{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 421cac059c..8fb259f8a4 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -170,7 +170,7 @@ {% if build.active %}
{% endif %} {% endif %} -
+
+
+
+ +
+ +
+
+
+
+
{% else %}
{% trans "This Build Order does not have any associated untracked BOM items" %} @@ -292,6 +304,7 @@ loadStockTable($("#build-stock-table"), { location_detail: true, part_detail: true, build: {{ build.id }}, + is_building: false, }, groupByField: 'location', buttons: [ @@ -305,6 +318,9 @@ var buildInfo = { quantity: {{ build.quantity }}, completed: {{ build.completed }}, part: {{ build.part.pk }}, + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} }; {% for item in build.incomplete_outputs %} @@ -400,13 +416,6 @@ $('#edit-notes').click(function() { }); }); -var buildInfo = { - pk: {{ build.pk }}, - quantity: {{ build.quantity }}, - completed: {{ build.completed }}, - part: {{ build.part.pk }}, -}; - {% if build.has_untracked_bom_items %} // Load allocation table for un-tracked parts loadBuildOutputAllocationTable(buildInfo, null); @@ -418,12 +427,38 @@ function reloadTable() { {% if build.active %} $("#btn-auto-allocate").on('click', function() { - launchModalForm( - "{% url 'build-auto-allocate' build.id %}", - { - success: reloadTable, + + var bom_items = $("#allocation-table-untracked").bootstrapTable("getData"); + + var incomplete_bom_items = []; + + bom_items.forEach(function(bom_item) { + if (bom_item.required > bom_item.allocated) { + incomplete_bom_items.push(bom_item); } - ); + }); + + if (incomplete_bom_items.length == 0) { + showAlertDialog( + '{% trans "Allocation Complete" %}', + '{% trans "All untracked stock items have been allocated" %}', + ); + } else { + + allocateStockToBuild( + {{ build.pk }}, + {{ build.part.pk }}, + incomplete_bom_items, + { + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} + success: function(data) { + $('#allocation-table-untracked').bootstrapTable('refresh'); + } + } + ); + } }); $('#btn-unallocate').on('click', function() { @@ -435,6 +470,25 @@ $('#btn-unallocate').on('click', function() { ); }); +$('#allocate-selected-items').click(function() { + + var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections"); + + allocateStockToBuild( + {{ build.pk }}, + {{ build.part.pk }}, + bom_items, + { + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} + success: function(data) { + $('#allocation-table-untracked').bootstrapTable('refresh'); + } + } + ); +}); + $("#btn-order-parts").click(function() { launchModalForm("/order/purchase-order/order-parts/", { data: { diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index a1d0c3df9f..017f0126c5 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from django.urls import reverse from part.models import Part -from build.models import Build +from build.models import Build, BuildItem from InvenTree.status_codes import BuildStatus from InvenTree.api_tester import InvenTreeAPITestCase @@ -23,6 +23,7 @@ class BuildAPITest(InvenTreeAPITestCase): 'location', 'bom', 'build', + 'stock', ] # Required roles to access Build API endpoints @@ -36,6 +37,192 @@ class BuildAPITest(InvenTreeAPITestCase): super().setUp() +class BuildAllocationTest(BuildAPITest): + """ + Unit tests for allocation of stock items against a build order. + + For this test, we will be using Build ID=1; + + - This points to Part 100 (see fixture data in part.yaml) + - This Part already has a BOM with 4 items (see fixture data in bom.yaml) + - There are no BomItem objects yet created for this build + + """ + + def setUp(self): + + super().setUp() + + self.assignRole('build.add') + self.assignRole('build.change') + + self.url = reverse('api-build-allocate', kwargs={'pk': 1}) + + self.build = Build.objects.get(pk=1) + + # Record number of build items which exist at the start of each test + self.n = BuildItem.objects.count() + + def test_build_data(self): + """ + Check that our assumptions about the particular BuildOrder are correct + """ + + self.assertEqual(self.build.part.pk, 100) + + # There should be 4x BOM items we can use + self.assertEqual(self.build.part.bom_items.count(), 4) + + # No items yet allocated to this build + self.assertEqual(self.build.allocated_stock.count(), 0) + + def test_get(self): + """ + A GET request to the endpoint should return an error + """ + + self.get(self.url, expected_code=405) + + def test_options(self): + """ + An OPTIONS request to the endpoint should return information about the endpoint + """ + + response = self.options(self.url, expected_code=200) + + self.assertIn("API endpoint to allocate stock items to a build order", str(response.data)) + + def test_empty(self): + """ + Test without any POST data + """ + + # Initially test with an empty data set + data = self.post(self.url, {}, expected_code=400).data + + self.assertIn('This field is required', str(data['items'])) + + # Now test but with an empty items list + data = self.post( + self.url, + { + "items": [] + }, + expected_code=400 + ).data + + self.assertIn('Allocation items must be provided', str(data)) + + # No new BuildItem objects have been created during this test + self.assertEqual(self.n, BuildItem.objects.count()) + + def test_missing(self): + """ + Test with missing data + """ + + # Missing quantity + data = self.post( + self.url, + { + "items": [ + { + "bom_item": 1, # M2x4 LPHS + "stock_item": 2, # 5,000 screws available + } + ] + }, + expected_code=400 + ).data + + self.assertIn('This field is required', str(data["items"][0]["quantity"])) + + # Missing bom_item + data = self.post( + self.url, + { + "items": [ + { + "stock_item": 2, + "quantity": 5000, + } + ] + }, + expected_code=400 + ).data + + self.assertIn("This field is required", str(data["items"][0]["bom_item"])) + + # Missing stock_item + data = self.post( + self.url, + { + "items": [ + { + "bom_item": 1, + "quantity": 5000, + } + ] + }, + expected_code=400 + ).data + + self.assertIn("This field is required", str(data["items"][0]["stock_item"])) + + # No new BuildItem objects have been created during this test + self.assertEqual(self.n, BuildItem.objects.count()) + + def test_invalid_bom_item(self): + """ + Test by passing an invalid BOM item + """ + + data = self.post( + self.url, + { + "items": [ + { + "bom_item": 5, + "stock_item": 11, + "quantity": 500, + } + ] + }, + expected_code=400 + ).data + + self.assertIn('must point to the same part', str(data)) + + def test_valid_data(self): + """ + Test with valid data. + This should result in creation of a new BuildItem object + """ + + self.post( + self.url, + { + "items": [ + { + "bom_item": 1, + "stock_item": 2, + "quantity": 5000, + } + ] + }, + expected_code=201 + ) + + # A new BuildItem should have been created + self.assertEqual(self.n + 1, BuildItem.objects.count()) + + allocation = BuildItem.objects.last() + + self.assertEqual(allocation.quantity, 5000) + self.assertEqual(allocation.bom_item.pk, 1) + self.assertEqual(allocation.stock_item.pk, 2) + + class BuildListTest(BuildAPITest): """ Tests for the BuildOrder LIST API diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 30fe8c488b..04b46bbd26 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -269,25 +269,6 @@ class BuildTest(TestCase): self.assertTrue(self.build.areUntrackedPartsFullyAllocated()) - def test_auto_allocate(self): - """ - Test auto-allocation functionality against the build outputs. - - Note: auto-allocations only work for un-tracked stock! - """ - - allocations = self.build.getAutoAllocations() - - self.assertEqual(len(allocations), 1) - - self.build.autoAllocate() - self.assertEqual(BuildItem.objects.count(), 1) - - # Check that one un-tracked part has been fully allocated to the build - self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, None)) - - self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, None)) - def test_cancel(self): """ Test cancellation of the build diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index b5e5406f69..93c6bfd511 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -172,7 +172,7 @@ class TestBuildAPI(APITestCase): # Filter by 'part' status response = self.client.get(url, {'part': 25}, format='json') - self.assertEqual(len(response.data), 2) + self.assertEqual(len(response.data), 1) # Filter by an invalid part response = self.client.get(url, {'part': 99999}, format='json') @@ -252,34 +252,6 @@ class TestBuildViews(TestCase): self.assertIn(build.title, content) - def test_build_item_create(self): - """ Test the BuildItem creation view (ajax form) """ - - url = reverse('build-item-create') - - # Try without a part specified - response = self.client.get(url, {'build': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Try with an invalid build ID - response = self.client.get(url, {'build': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Try with a valid part specified - response = self.client.get(url, {'build': 1, 'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Try with an invalid part specified - response = self.client.get(url, {'build': 1, 'part': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_build_item_edit(self): - """ Test the BuildItem edit view (ajax form) """ - - # TODO - # url = reverse('build-item-edit') - pass - def test_build_output_complete(self): """ Test the build output completion form diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 9814dc83f7..050c32209b 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -12,7 +12,6 @@ build_detail_urls = [ url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'), url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'), url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'), - url(r'^auto-allocate/', views.BuildAutoAllocate.as_view(), name='build-auto-allocate'), url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'), url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'), @@ -20,13 +19,6 @@ build_detail_urls = [ ] build_urls = [ - url(r'item/', include([ - url(r'^(?P\d+)/', include([ - url('^edit/', views.BuildItemEdit.as_view(), name='build-item-edit'), - url('^delete/', views.BuildItemDelete.as_view(), name='build-item-delete'), - ])), - url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'), - ])), url(r'^(?P\d+)/', include(build_detail_urls)), diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index dfa655f9a4..702b3b3596 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -11,13 +11,13 @@ from django.views.generic import DetailView, ListView from django.forms import HiddenInput from part.models import Part -from .models import Build, BuildItem +from .models import Build from . import forms from stock.models import StockLocation, StockItem -from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView +from InvenTree.views import AjaxUpdateView, AjaxDeleteView from InvenTree.views import InvenTreeRoleMixin -from InvenTree.helpers import str2bool, extract_serial_numbers, normalize, isNull +from InvenTree.helpers import str2bool, extract_serial_numbers, isNull from InvenTree.status_codes import BuildStatus, StockStatus @@ -77,67 +77,6 @@ class BuildCancel(AjaxUpdateView): } -class BuildAutoAllocate(AjaxUpdateView): - """ View to auto-allocate parts for a build. - Follows a simple set of rules to automatically allocate StockItem objects. - - Ref: build.models.Build.getAutoAllocations() - """ - - model = Build - form_class = forms.AutoAllocateForm - context_object_name = 'build' - ajax_form_title = _('Allocate Stock') - ajax_template_name = 'build/auto_allocate.html' - - def get_initial(self): - """ - Initial values for the form. - """ - - initials = super().get_initial() - - return initials - - def get_context_data(self, *args, **kwargs): - """ - Get the context data for form rendering. - """ - - context = {} - - build = self.get_object() - - context['allocations'] = build.getAutoAllocations() - - context['build'] = build - - return context - - def get_form(self): - - form = super().get_form() - - return form - - def validate(self, build, form, **kwargs): - - pass - - def save(self, build, form, **kwargs): - """ - Once the form has been validated, - perform auto-allocations - """ - - build.autoAllocate() - - def get_data(self): - return { - 'success': _('Allocated stock to build output'), - } - - class BuildOutputCreate(AjaxUpdateView): """ Create a new build output (StockItem) for a given build. @@ -626,268 +565,3 @@ class BuildDelete(AjaxDeleteView): model = Build ajax_template_name = 'build/delete_build.html' ajax_form_title = _('Delete Build Order') - - -class BuildItemDelete(AjaxDeleteView): - """ View to 'unallocate' a BuildItem. - Really we are deleting the BuildItem object from the database. - """ - - model = BuildItem - ajax_template_name = 'build/delete_build_item.html' - ajax_form_title = _('Unallocate Stock') - context_object_name = 'item' - - def get_data(self): - return { - 'danger': _('Removed parts from build allocation') - } - - -class BuildItemCreate(AjaxCreateView): - """ - View for allocating a StockItem to a build output. - """ - - model = BuildItem - form_class = forms.EditBuildItemForm - ajax_template_name = 'build/create_build_item.html' - ajax_form_title = _('Allocate stock to build output') - - # The output StockItem against which the allocation is being made - output = None - - # The "part" which is being allocated to the output - part = None - - available_stock = None - - def get_context_data(self): - """ - Provide context data to the template which renders the form. - """ - - ctx = super().get_context_data() - - if self.part: - ctx['part'] = self.part - - if self.output: - ctx['output'] = self.output - - if self.available_stock: - ctx['stock'] = self.available_stock - else: - ctx['no_stock'] = True - - return ctx - - def validate(self, build_item, form, **kwargs): - """ - Extra validation steps as required - """ - - data = form.cleaned_data - - stock_item = data.get('stock_item', None) - quantity = data.get('quantity', None) - - if stock_item: - # Stock item must actually be in stock! - if not stock_item.in_stock: - form.add_error('stock_item', _('Item must be currently in stock')) - - # Check that there are enough items available - if quantity is not None: - available = stock_item.unallocated_quantity() - if quantity > available: - form.add_error('stock_item', _('Stock item is over-allocated')) - form.add_error('quantity', _('Available') + ': ' + str(normalize(available))) - else: - form.add_error('stock_item', _('Stock item must be selected')) - - def get_form(self): - """ Create Form for making / editing new Part object """ - - form = super(AjaxCreateView, self).get_form() - - self.build = None - self.part = None - self.output = None - - # If the Build object is specified, hide the input field. - # We do not want the users to be able to move a BuildItem to a different build - build_id = form['build'].value() - - if build_id is not None: - """ - If the build has been provided, hide the widget to change the build selection. - Additionally, update the allowable selections for other fields. - """ - form.fields['build'].widget = HiddenInput() - form.fields['install_into'].queryset = StockItem.objects.filter(build=build_id, is_building=True) - self.build = Build.objects.get(pk=build_id) - else: - """ - Build has *not* been selected - """ - pass - - # If the sub_part is supplied, limit to matching stock items - part_id = form['part_id'].value() - - if part_id: - try: - self.part = Part.objects.get(pk=part_id) - - except (ValueError, Part.DoesNotExist): - pass - - # If the output stock item is specified, hide the input field - output_id = form['install_into'].value() - - if output_id is not None: - - try: - self.output = StockItem.objects.get(pk=output_id) - form.fields['install_into'].widget = HiddenInput() - except (ValueError, StockItem.DoesNotExist): - pass - - else: - # If the output is not specified, but we know that the part is non-trackable, hide the install_into field - if self.part and not self.part.trackable: - form.fields['install_into'].widget = HiddenInput() - - if self.build and self.part: - available_items = self.build.availableStockItems(self.part, self.output) - - form.fields['stock_item'].queryset = available_items - - self.available_stock = form.fields['stock_item'].queryset.all() - - # If there is only a single stockitem available, select it! - if len(self.available_stock) == 1: - form.fields['stock_item'].initial = self.available_stock[0].pk - - return form - - def get_initial(self): - """ Provide initial data for BomItem. Look for the folllowing in the GET data: - - - build: pk of the Build object - - part: pk of the Part object which we are assigning - - output: pk of the StockItem object into which the allocated stock will be installed - """ - - initials = super(AjaxCreateView, self).get_initial().copy() - - build_id = self.get_param('build') - part_id = self.get_param('part') - output_id = self.get_param('install_into') - - # Reference to a Part object - part = None - - # Reference to a StockItem object - item = None - - # Reference to a Build object - build = None - - # Reference to a StockItem object - output = None - - if part_id: - try: - part = Part.objects.get(pk=part_id) - initials['part_id'] = part.pk - except Part.DoesNotExist: - pass - - if build_id: - try: - build = Build.objects.get(pk=build_id) - initials['build'] = build - except Build.DoesNotExist: - pass - - # If the output has been specified - if output_id: - try: - output = StockItem.objects.get(pk=output_id) - initials['install_into'] = output - except (ValueError, StockItem.DoesNotExist): - pass - - # Work out how much stock is required - if build and part: - required_quantity = build.unallocatedQuantity(part, output) - else: - required_quantity = None - - quantity = self.request.GET.get('quantity', None) - - if quantity is not None: - quantity = float(quantity) - elif required_quantity is not None: - quantity = required_quantity - - item_id = self.get_param('item') - - # If the request specifies a particular StockItem - if item_id: - try: - item = StockItem.objects.get(pk=item_id) - except (ValueError, StockItem.DoesNotExist): - pass - - # If a StockItem is not selected, try to auto-select one - if item is None and part is not None: - items = StockItem.objects.filter(part=part) - if items.count() == 1: - item = items.first() - - # Finally, if a StockItem is selected, ensure the quantity is not too much - if item is not None: - if quantity is None: - quantity = item.unallocated_quantity() - else: - quantity = min(quantity, item.unallocated_quantity()) - - if quantity is not None: - initials['quantity'] = quantity - - return initials - - -class BuildItemEdit(AjaxUpdateView): - """ View to edit a BuildItem object """ - - model = BuildItem - ajax_template_name = 'build/edit_build_item.html' - form_class = forms.EditBuildItemForm - ajax_form_title = _('Edit Stock Allocation') - - def get_data(self): - return { - 'info': _('Updated Build Item'), - } - - def get_form(self): - """ - Create form for editing a BuildItem. - - - Limit the StockItem options to items that match the part - """ - - form = super(BuildItemEdit, self).get_form() - - # Hide fields which we do not wish the user to edit - for field in ['build', 'stock_item']: - if form[field].value(): - form.fields[field].widget = HiddenInput() - - form.fields['install_into'].widget = HiddenInput() - - return form diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 76798c5ad4..41371dd739 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -1,18 +1,6 @@ from __future__ import unicode_literals -import os -import logging - -from PIL import UnidentifiedImageError - from django.apps import AppConfig -from django.db.utils import OperationalError, ProgrammingError -from django.conf import settings - -from InvenTree.ready import canAppAccessDatabase - - -logger = logging.getLogger("inventree") class CompanyConfig(AppConfig): @@ -23,29 +11,4 @@ class CompanyConfig(AppConfig): This function is called whenever the Company app is loaded. """ - if canAppAccessDatabase(): - self.generate_company_thumbs() - - def generate_company_thumbs(self): - - from .models import Company - - logger.debug("Checking Company image thumbnails") - - try: - for company in Company.objects.all(): - if company.image: - url = company.image.thumbnail.name - loc = os.path.join(settings.MEDIA_ROOT, url) - - if not os.path.exists(loc): - logger.info("InvenTree: Generating thumbnail for Company '{c}'".format(c=company.name)) - try: - company.image.render_variations(replace=False) - except FileNotFoundError: - logger.warning(f"Image file '{company.image}' missing") - except UnidentifiedImageError: - logger.warning(f"Image file '{company.image}' is invalid") - except (OperationalError, ProgrammingError): - # Getting here probably meant the database was in test mode - pass + pass diff --git a/InvenTree/company/migrations/0041_alter_company_options.py b/InvenTree/company/migrations/0041_alter_company_options.py new file mode 100644 index 0000000000..40849eed1d --- /dev/null +++ b/InvenTree/company/migrations/0041_alter_company_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.5 on 2021-10-04 20:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0040_alter_company_currency'), + ] + + operations = [ + migrations.AlterModelOptions( + name='company', + options={'ordering': ['name'], 'verbose_name_plural': 'Companies'}, + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index b0bb8caaa5..ebe61a74b0 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -94,6 +94,7 @@ class Company(models.Model): constraints = [ UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair') ] + verbose_name_plural = "Companies" name = models.CharField(max_length=100, blank=False, help_text=_('Company name'), diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index ab6c4d7c0b..26e6ed3546 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -7,14 +7,12 @@ from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ from django.conf.urls import url, include -from django.db import transaction -from django.core.exceptions import ValidationError as DjangoValidationError +from django.db.models import Q, F from django_filters import rest_framework as rest_filters from rest_framework import generics from rest_framework import filters, status from rest_framework.response import Response -from rest_framework import serializers from rest_framework.serializers import ValidationError @@ -235,6 +233,7 @@ class POReceive(generics.CreateAPIView): # Pass the purchase order through to the serializer for validation context['order'] = self.get_order() + context['request'] = self.request return context @@ -252,75 +251,38 @@ class POReceive(generics.CreateAPIView): return order - def create(self, request, *args, **kwargs): - # Which purchase order are we receiving against? - self.order = self.get_order() +class POLineItemFilter(rest_filters.FilterSet): + """ + Custom filters for the POLineItemList endpoint + """ - # Validate the serialized data - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) + class Meta: + model = PurchaseOrderLineItem + fields = [ + 'order', + 'part' + ] - # Receive the line items - try: - self.receive_items(serializer) - except DjangoValidationError as exc: - # Re-throw a django error as a DRF error - raise ValidationError(detail=serializers.as_serializer_error(exc)) + completed = rest_filters.BooleanFilter(label='completed', method='filter_completed') - headers = self.get_success_headers(serializer.data) - - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - - @transaction.atomic - def receive_items(self, serializer): + def filter_completed(self, queryset, name, value): """ - Receive the items + Filter by lines which are "completed" (or "not" completed) - At this point, much of the heavy lifting has been done for us by DRF serializers! - - We have a list of "items", each a dict which contains: - - line_item: A PurchaseOrderLineItem matching this order - - location: A destination location - - quantity: A validated numerical quantity - - status: The status code for the received item + A line is completed when received >= quantity """ - data = serializer.validated_data + value = str2bool(value) - location = data['location'] + q = Q(received__gte=F('quantity')) - items = data['items'] + if value: + queryset = queryset.filter(q) + else: + queryset = queryset.exclude(q) - # Check if the location is not specified for any particular item - for item in items: - - line = item['line_item'] - - if not item.get('location', None): - # If a global location is specified, use that - item['location'] = location - - if not item['location']: - # The line item specifies a location? - item['location'] = line.get_destination() - - if not item['location']: - raise ValidationError({ - 'location': _("Destination location must be specified"), - }) - - # Now we can actually receive the items - for item in items: - - self.order.receive_line_item( - item['line_item'], - item['location'], - item['quantity'], - self.request.user, - status=item['status'], - barcode=item.get('barcode', ''), - ) + return queryset class POLineItemList(generics.ListCreateAPIView): @@ -332,6 +294,7 @@ class POLineItemList(generics.ListCreateAPIView): queryset = PurchaseOrderLineItem.objects.all() serializer_class = POLineItemSerializer + filterset_class = POLineItemFilter def get_queryset(self, *args, **kwargs): diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index e0c500e5e3..87e042f4f3 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -8,8 +8,6 @@ from __future__ import unicode_literals from django import forms from django.utils.translation import ugettext_lazy as _ -from mptt.fields import TreeNodeChoiceField - from InvenTree.forms import HelperForm from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField @@ -19,7 +17,6 @@ from common.forms import MatchItemForm import part.models -from stock.models import StockLocation from .models import PurchaseOrder from .models import SalesOrder, SalesOrderLineItem from .models import SalesOrderAllocation @@ -80,22 +77,6 @@ class ShipSalesOrderForm(HelperForm): ] -class ReceivePurchaseOrderForm(HelperForm): - - location = TreeNodeChoiceField( - queryset=StockLocation.objects.all(), - required=False, - label=_("Destination"), - help_text=_("Set all received parts listed above to this location (if left blank, use \"Destination\" column value in above table)"), - ) - - class Meta: - model = PurchaseOrder - fields = [ - "location", - ] - - class AllocateSerialsToSalesOrderForm(forms.Form): """ Form for assigning stock to a sales order, diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index da2d23cd0d..3886bfd3a5 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -7,7 +7,8 @@ from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ -from django.db import models +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import models, transaction from django.db.models import Case, When, Value from django.db.models import BooleanField, ExpressionWrapper, F @@ -224,6 +225,13 @@ class POLineItemReceiveSerializer(serializers.Serializer): required=True, ) + def validate_quantity(self, quantity): + + if quantity <= 0: + raise ValidationError(_("Quantity must be greater than zero")) + + return quantity + status = serializers.ChoiceField( choices=list(StockStatus.items()), default=StockStatus.OK, @@ -235,6 +243,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): help_text=_('Unique identifier field'), default='', required=False, + allow_blank=True, ) def validate_barcode(self, barcode): @@ -244,7 +253,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): # Ignore empty barcode values if not barcode or barcode.strip() == '': - return + return None if stock.models.StockItem.objects.filter(uid=barcode).exists(): raise ValidationError(_('Barcode is already in use')) @@ -276,35 +285,81 @@ class POReceiveSerializer(serializers.Serializer): help_text=_('Select destination location for received items'), ) - def is_valid(self, raise_exception=False): + def validate(self, data): - super().is_valid(raise_exception) - - # Custom validation - data = self.validated_data + super().validate(data) items = data.get('items', []) + location = data.get('location', None) + if len(items) == 0: - self._errors['items'] = _('Line items must be provided') - else: - # Ensure barcodes are unique - unique_barcodes = set() + raise ValidationError(_('Line items must be provided')) + # Check if the location is not specified for any particular item + for item in items: + + line = item['line_item'] + + if not item.get('location', None): + # If a global location is specified, use that + item['location'] = location + + if not item['location']: + # The line item specifies a location? + item['location'] = line.get_destination() + + if not item['location']: + raise ValidationError({ + 'location': _("Destination location must be specified"), + }) + + # Ensure barcodes are unique + unique_barcodes = set() + + for item in items: + barcode = item.get('barcode', '') + + if barcode: + if barcode in unique_barcodes: + raise ValidationError(_('Supplied barcode values must be unique')) + else: + unique_barcodes.add(barcode) + + return data + + def save(self): + """ + Perform the actual database transaction to receive purchase order items + """ + + data = self.validated_data + + request = self.context['request'] + order = self.context['order'] + + items = data['items'] + location = data.get('location', None) + + # Now we can actually receive the items into stock + with transaction.atomic(): for item in items: - barcode = item.get('barcode', '') - if barcode: - if barcode in unique_barcodes: - self._errors['items'] = _('Supplied barcode values must be unique') - break - else: - unique_barcodes.add(barcode) + # Select location + loc = item.get('location', None) or item['line_item'].get_destination() or location - if self._errors and raise_exception: - raise ValidationError(self.errors) - - return not bool(self._errors) + try: + order.receive_line_item( + item['line_item'], + loc, + item['quantity'], + request.user, + status=item['status'], + barcode=item.get('barcode', ''), + ) + except (ValidationError, DjangoValidationError) as exc: + # Catch model errors and re-throw as DRF errors + raise ValidationError(detail=serializers.as_serializer_error(exc)) class Meta: fields = [ diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 0d46207c33..69e972da6c 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -49,7 +49,7 @@ src="{% static 'img/blank_image.png' %}" {% elif order.status == PurchaseOrderStatus.PLACED %}
- {% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %} + {% if roles.purchase_order.change %} + {% if order.status == PurchaseOrderStatus.PENDING %} {% trans "Upload File" %} + {% elif order.status == PurchaseOrderStatus.PLACED %} + {% endif %} + {% endif %} +
+ +
@@ -207,6 +216,22 @@ $('#new-po-line').click(function() { }); }); +{% elif order.status == PurchaseOrderStatus.PLACED %} + + $('#receive-selected-items').click(function() { + var items = $("#po-line-table").bootstrapTable('getSelections'); + + receivePurchaseOrderItems( + {{ order.id }}, + items, + { + success: function() { + $("#po-line-table").bootstrapTable('refresh'); + } + } + ); + }); + {% endif %} loadPurchaseOrderLineItemTable('#po-line-table', { diff --git a/InvenTree/order/templates/order/receive_parts.html b/InvenTree/order/templates/order/receive_parts.html deleted file mode 100644 index 7b12101f7f..0000000000 --- a/InvenTree/order/templates/order/receive_parts.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} -{% load inventree_extras %} -{% load status_codes %} - -{% block form %} - -{% blocktrans with desc=order.description %}Receive outstanding parts for {{order}} - {{desc}}{% endblocktrans %} - - - {% csrf_token %} - {% load crispy_forms_tags %} - - -

{% trans "Fill out number of parts received, the status and destination" %}

- -
- - - - - - - - - - - {% for line in lines %} - - {% if line.part %} - - - {% else %} - - {% endif %} - - - - - - - - {% endfor %} -
{% trans "Part" %}{% trans "Order Code" %}{% trans "On Order" %}{% trans "Received" %}{% trans "Receive" %}{% trans "Status" %}{% trans "Destination" %}
- {% include "hover_image.html" with image=line.part.part.image hover=False %} - {{ line.part.part.full_name }} - {{ line.part.SKU }}{% trans "Error: Referenced part has been removed" %}{% decimal line.quantity %}{% decimal line.received %} -
-
- -
-
-
-
- -
-
-
- -
-
- -
- - {% crispy form %} - -
{{ form_errors }}
- - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 765c58cc3d..1f7905d1e3 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -251,7 +251,7 @@ class PurchaseOrderReceiveTest(OrderTest): expected_code=400 ).data - self.assertIn('Line items must be provided', str(data['items'])) + self.assertIn('Line items must be provided', str(data)) # No new stock items have been created self.assertEqual(self.n, StockItem.objects.count()) diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py index 4b49b6c94e..220c1688db 100644 --- a/InvenTree/order/test_views.py +++ b/InvenTree/order/test_views.py @@ -10,7 +10,7 @@ from django.contrib.auth.models import Group from InvenTree.status_codes import PurchaseOrderStatus -from .models import PurchaseOrder, PurchaseOrderLineItem +from .models import PurchaseOrder import json @@ -103,86 +103,3 @@ class POTests(OrderViewTestCase): # Test that the order was actually placed order = PurchaseOrder.objects.get(pk=1) self.assertEqual(order.status, PurchaseOrderStatus.PLACED) - - -class TestPOReceive(OrderViewTestCase): - """ Tests for receiving a purchase order """ - - def setUp(self): - super().setUp() - - self.po = PurchaseOrder.objects.get(pk=1) - self.po.status = PurchaseOrderStatus.PLACED - self.po.save() - self.url = reverse('po-receive', args=(1,)) - - def post(self, data, validate=None): - - response = self.client.post(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - if validate is not None: - - data = json.loads(response.content) - - if validate: - self.assertTrue(data['form_valid']) - else: - self.assertFalse(data['form_valid']) - - return response - - def test_get_dialog(self): - - data = { - } - - self.client.get(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - def test_receive_lines(self): - - post_data = { - } - - self.post(post_data, validate=False) - - # Try with an invalid location - post_data['location'] = 12345 - - self.post(post_data, validate=False) - - # Try with a valid location - post_data['location'] = 1 - - # Should fail due to invalid quantity - self.post(post_data, validate=False) - - # Try to receive against an invalid line - post_data['line-800'] = 100 - - # Remove an invalid quantity of items - post_data['line-1'] = '7x5q' - - self.post(post_data, validate=False) - - # Receive negative number - post_data['line-1'] = -100 - - self.post(post_data, validate=False) - - # Receive 75 items - post_data['line-1'] = 75 - - self.post(post_data, validate=True) - - line = PurchaseOrderLineItem.objects.get(pk=1) - - self.assertEqual(line.received, 75) - - # Receive 30 more items - post_data['line-1'] = 30 - - self.post(post_data, validate=True) - - line = PurchaseOrderLineItem.objects.get(pk=1) - - self.assertEqual(line.received, 105) diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 2ce90f1f81..5ea9a56867 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -13,7 +13,6 @@ purchase_order_detail_urls = [ url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'), url(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'), - url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'), url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'), url(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'), diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index e8b0dc03e9..8a5e709926 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -26,7 +26,7 @@ from .models import SalesOrderAllocation from .admin import POLineItemResource from build.models import Build from company.models import Company, SupplierPart # ManufacturerPart -from stock.models import StockItem, StockLocation +from stock.models import StockItem from part.models import Part from common.models import InvenTreeSetting @@ -42,7 +42,7 @@ from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers import extract_serial_numbers from InvenTree.views import InvenTreeRoleMixin -from InvenTree.status_codes import PurchaseOrderStatus, StockStatus +from InvenTree.status_codes import PurchaseOrderStatus logger = logging.getLogger("inventree") @@ -468,202 +468,6 @@ class PurchaseOrderExport(AjaxView): return DownloadFile(filedata, filename) -class PurchaseOrderReceive(AjaxUpdateView): - """ View for receiving parts which are outstanding against a PurchaseOrder. - - Any parts which are outstanding are listed. - If all parts are marked as received, the order is closed out. - - """ - - form_class = order_forms.ReceivePurchaseOrderForm - ajax_form_title = _("Receive Parts") - ajax_template_name = "order/receive_parts.html" - - # Specify role as we do not specify a Model against this view - role_required = 'purchase_order.change' - - # Where the parts will be going (selected in POST request) - destination = None - - def get_context_data(self): - - ctx = { - 'order': self.order, - 'lines': self.lines, - 'stock_locations': StockLocation.objects.all(), - } - - return ctx - - def get_lines(self): - """ - Extract particular line items from the request, - or default to *all* pending line items if none are provided - """ - - lines = None - - if 'line' in self.request.GET: - line_id = self.request.GET.get('line') - - try: - lines = PurchaseOrderLineItem.objects.filter(pk=line_id) - except (PurchaseOrderLineItem.DoesNotExist, ValueError): - pass - - # TODO - Option to pass multiple lines? - - # No lines specified - default selection - if lines is None: - lines = self.order.pending_line_items() - - return lines - - def get(self, request, *args, **kwargs): - """ Respond to a GET request. Determines which parts are outstanding, - and presents a list of these parts to the user. - """ - - self.request = request - self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) - - self.lines = self.get_lines() - - for line in self.lines: - # Pre-fill the remaining quantity - line.receive_quantity = line.remaining() - - return self.renderJsonResponse(request, form=self.get_form()) - - def post(self, request, *args, **kwargs): - """ Respond to a POST request. Data checking and error handling. - If the request is valid, new StockItem objects will be made - for each received item. - """ - - self.request = request - self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) - errors = False - - self.lines = [] - self.destination = None - - msg = _("Items received") - - # Extract the destination for received parts - if 'location' in request.POST: - pk = request.POST['location'] - try: - self.destination = StockLocation.objects.get(id=pk) - except (StockLocation.DoesNotExist, ValueError): - pass - - # Extract information on all submitted line items - for item in request.POST: - if item.startswith('line-'): - pk = item.replace('line-', '') - - try: - line = PurchaseOrderLineItem.objects.get(id=pk) - except (PurchaseOrderLineItem.DoesNotExist, ValueError): - continue - - # Check that the StockStatus was set - status_key = 'status-{pk}'.format(pk=pk) - status = request.POST.get(status_key, StockStatus.OK) - - try: - status = int(status) - except ValueError: - status = StockStatus.OK - - if status in StockStatus.RECEIVING_CODES: - line.status_code = status - else: - line.status_code = StockStatus.OK - - # Check the destination field - line.destination = None - if self.destination: - # If global destination is set, overwrite line value - line.destination = self.destination - else: - destination_key = f'destination-{pk}' - destination = request.POST.get(destination_key, None) - - if destination: - try: - line.destination = StockLocation.objects.get(pk=destination) - except (StockLocation.DoesNotExist, ValueError): - pass - - # Check that line matches the order - if not line.order == self.order: - # TODO - Display a non-field error? - continue - - # Ignore a part that doesn't map to a SupplierPart - try: - if line.part is None: - continue - except SupplierPart.DoesNotExist: - continue - - receive = self.request.POST[item] - - try: - receive = Decimal(receive) - except InvalidOperation: - # In the case on an invalid input, reset to default - receive = line.remaining() - msg = _("Error converting quantity to number") - errors = True - - if receive < 0: - receive = 0 - errors = True - msg = _("Receive quantity less than zero") - - line.receive_quantity = receive - self.lines.append(line) - - if len(self.lines) == 0: - msg = _("No lines specified") - errors = True - - # No errors? Receive the submitted parts! - if errors is False: - self.receive_parts() - - data = { - 'form_valid': errors is False, - 'success': msg, - } - - return self.renderJsonResponse(request, data=data, form=self.get_form()) - - @transaction.atomic - def receive_parts(self): - """ Called once the form has been validated. - Create new stockitems against received parts. - """ - - for line in self.lines: - - if not line.part: - continue - - self.order.receive_line_item( - line, - line.destination, - line.receive_quantity, - self.request.user, - status=line.status_code, - purchase_price=line.purchase_price, - ) - - class OrderParts(AjaxView): """ View for adding various SupplierPart items to a Purchase Order. diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index 0c57e2c1ab..49a9f2f90c 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -1,13 +1,9 @@ from __future__ import unicode_literals -import os import logging from django.db.utils import OperationalError, ProgrammingError from django.apps import AppConfig -from django.conf import settings - -from PIL import UnidentifiedImageError from InvenTree.ready import canAppAccessDatabase @@ -24,40 +20,8 @@ class PartConfig(AppConfig): """ if canAppAccessDatabase(): - self.generate_part_thumbnails() self.update_trackable_status() - def generate_part_thumbnails(self): - """ - Generate thumbnail images for any Part that does not have one. - This function exists mainly for legacy support, - as any *new* image uploaded will have a thumbnail generated automatically. - """ - - from .models import Part - - logger.debug("InvenTree: Checking Part image thumbnails") - - try: - # Only check parts which have images - for part in Part.objects.exclude(image=None): - if part.image: - url = part.image.thumbnail.name - loc = os.path.join(settings.MEDIA_ROOT, url) - - if not os.path.exists(loc): - logger.info("InvenTree: Generating thumbnail for Part '{p}'".format(p=part.name)) - try: - part.image.render_variations(replace=False) - except FileNotFoundError: - logger.warning(f"Image file '{part.image}' missing") - pass - except UnidentifiedImageError: - logger.warning(f"Image file '{part.image}' is invalid") - except (OperationalError, ProgrammingError): - # Exception if the database has not been migrated yet - pass - def update_trackable_status(self): """ Check for any instances where a trackable part is used in the BOM @@ -72,7 +36,7 @@ class PartConfig(AppConfig): items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True) for item in items: - print(f"Marking part '{item.part.name}' as trackable") + logger.info(f"Marking part '{item.part.name}' as trackable") item.part.trackable = True item.part.clean() item.part.save() diff --git a/InvenTree/part/fixtures/bom.yaml b/InvenTree/part/fixtures/bom.yaml index a9e1bed6f0..e879b8381f 100644 --- a/InvenTree/part/fixtures/bom.yaml +++ b/InvenTree/part/fixtures/bom.yaml @@ -30,4 +30,11 @@ fields: part: 100 sub_part: 50 - quantity: 3 \ No newline at end of file + quantity: 3 + +- model: part.bomitem + pk: 5 + fields: + part: 1 + sub_part: 5 + quantity: 3 diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index d7ad577081..8c43a623a0 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -4,6 +4,7 @@ Part database model definitions # -*- coding: utf-8 -*- from __future__ import unicode_literals +import decimal import os import logging @@ -1530,10 +1531,13 @@ class Part(MPTTModel): for item in self.get_bom_items().all().select_related('sub_part'): if item.sub_part.pk == self.pk: - print("Warning: Item contains itself in BOM") + logger.warning(f"WARNING: BomItem ID {item.pk} contains itself in BOM") continue - prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal, purchase=purchase) + q = decimal.Decimal(quantity) + i = decimal.Decimal(item.quantity) + + prices = item.sub_part.get_price_range(q * i, internal=internal, purchase=purchase) if prices is None: continue @@ -2329,6 +2333,23 @@ class BomItem(models.Model): def get_api_url(): return reverse('api-bom-list') + def get_stock_filter(self): + """ + Return a queryset filter for selecting StockItems which match this BomItem + + - If allow_variants is True, allow all part variants + + """ + + # Target part + part = self.sub_part + + if self.allow_variants: + variants = part.get_descendants(include_self=True) + return Q(part__in=[v.pk for v in variants]) + else: + return Q(part=part) + def save(self, *args, **kwargs): self.clean() diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 060faf8b0d..4f1ba8cc8b 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -193,6 +193,7 @@ class PartBriefSerializer(InvenTreeModelSerializer): fields = [ 'pk', 'IPN', + 'default_location', 'name', 'revision', 'full_name', diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 847baf8ab5..a81f918013 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -372,7 +372,7 @@ { success: function(items) { adjustStock(action, items, { - onSuccess: function() { + success: function() { location.reload(); } }); diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 660b573e33..ac9d6bdf45 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -277,7 +277,7 @@ class PartAPITest(InvenTreeAPITestCase): """ There should be 4 BomItem objects in the database """ url = reverse('api-bom-list') response = self.client.get(url, format='json') - self.assertEqual(len(response.data), 4) + self.assertEqual(len(response.data), 5) def test_get_bom_detail(self): # Get the detail for a single BomItem diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 66897b28fc..be9740d128 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -120,7 +120,13 @@ class BomItemTest(TestCase): def test_pricing(self): self.bob.get_price(1) - self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(84.5), Decimal(89.5))) + self.assertEqual( + self.bob.get_bom_price_range(1, internal=True), + (Decimal(29.5), Decimal(89.5)) + ) # remove internal price for R_2K2_0805 self.r1.internal_price_breaks.delete() - self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(82.5), Decimal(87.5))) + self.assertEqual( + self.bob.get_bom_price_range(1, internal=True), + (Decimal(27.5), Decimal(87.5)) + ) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index eaa65dd763..ad487c7a5a 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -2,15 +2,20 @@ JSON API for the Stock app """ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from datetime import datetime, timedelta + +from django.utils.translation import ugettext_lazy as _ + from django.conf.urls import url, include from django.urls import reverse from django.http import JsonResponse from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ from rest_framework import status from rest_framework.serializers import ValidationError -from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import generics, filters, permissions @@ -22,7 +27,7 @@ from .models import StockItemTracking from .models import StockItemAttachment from .models import StockItemTestResult -from part.models import Part, PartCategory +from part.models import BomItem, Part, PartCategory from part.serializers import PartBriefSerializer from company.models import Company, SupplierPart @@ -34,21 +39,13 @@ from order.serializers import POSerializer import common.settings import common.models -from .serializers import StockItemSerializer -from .serializers import LocationSerializer, LocationBriefSerializer -from .serializers import StockTrackingSerializer -from .serializers import StockItemAttachmentSerializer -from .serializers import StockItemTestResultSerializer +import stock.serializers as StockSerializers from InvenTree.views import TreeSerializer from InvenTree.helpers import str2bool, isNull from InvenTree.api import AttachmentMixin from InvenTree.filters import InvenTreeOrderingFilter -from decimal import Decimal, InvalidOperation - -from datetime import datetime, timedelta - class StockCategoryTree(TreeSerializer): title = _('Stock') @@ -80,12 +77,12 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): """ queryset = StockItem.objects.all() - serializer_class = StockItemSerializer + serializer_class = StockSerializers.StockItemSerializer def get_queryset(self, *args, **kwargs): queryset = super().get_queryset(*args, **kwargs) - queryset = StockItemSerializer.annotate_queryset(queryset) + queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset) return queryset @@ -121,7 +118,7 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): instance.mark_for_deletion() -class StockAdjust(APIView): +class StockAdjustView(generics.CreateAPIView): """ A generic class for handling stocktake actions. @@ -135,184 +132,57 @@ class StockAdjust(APIView): queryset = StockItem.objects.none() - allow_missing_quantity = False + def get_serializer_context(self): + + context = super().get_serializer_context() - def get_items(self, request): - """ - Return a list of items posted to the endpoint. - Will raise validation errors if the items are not - correctly formatted. - """ + context['request'] = self.request - _items = [] - - if 'item' in request.data: - _items = [request.data['item']] - elif 'items' in request.data: - _items = request.data['items'] - else: - _items = [] - - if len(_items) == 0: - raise ValidationError(_('Request must contain list of stock items')) - - # List of validated items - self.items = [] - - for entry in _items: - - if not type(entry) == dict: - raise ValidationError(_('Improperly formatted data')) - - # Look for a 'pk' value (use 'id' as a backup) - pk = entry.get('pk', entry.get('id', None)) - - try: - pk = int(pk) - except (ValueError, TypeError): - raise ValidationError(_('Each entry must contain a valid integer primary-key')) - - try: - item = StockItem.objects.get(pk=pk) - except (StockItem.DoesNotExist): - raise ValidationError({ - pk: [_('Primary key does not match valid stock item')] - }) - - if self.allow_missing_quantity and 'quantity' not in entry: - entry['quantity'] = item.quantity - - try: - quantity = Decimal(str(entry.get('quantity', None))) - except (ValueError, TypeError, InvalidOperation): - raise ValidationError({ - pk: [_('Invalid quantity value')] - }) - - if quantity < 0: - raise ValidationError({ - pk: [_('Quantity must not be less than zero')] - }) - - self.items.append({ - 'item': item, - 'quantity': quantity - }) - - # Extract 'notes' field - self.notes = str(request.data.get('notes', '')) + return context -class StockCount(StockAdjust): +class StockCount(StockAdjustView): """ Endpoint for counting stock (performing a stocktake). """ - def post(self, request, *args, **kwargs): - - self.get_items(request) - - n = 0 - - for item in self.items: - - if item['item'].stocktake(item['quantity'], request.user, notes=self.notes): - n += 1 - - return Response({'success': _('Updated stock for {n} items').format(n=n)}) + serializer_class = StockSerializers.StockCountSerializer -class StockAdd(StockAdjust): +class StockAdd(StockAdjustView): """ Endpoint for adding a quantity of stock to an existing StockItem """ - def post(self, request, *args, **kwargs): - - self.get_items(request) - - n = 0 - - for item in self.items: - if item['item'].add_stock(item['quantity'], request.user, notes=self.notes): - n += 1 - - return Response({"success": "Added stock for {n} items".format(n=n)}) + serializer_class = StockSerializers.StockAddSerializer -class StockRemove(StockAdjust): +class StockRemove(StockAdjustView): """ Endpoint for removing a quantity of stock from an existing StockItem. """ - def post(self, request, *args, **kwargs): - - self.get_items(request) - - n = 0 - - for item in self.items: - - if item['quantity'] > item['item'].quantity: - raise ValidationError({ - item['item'].pk: [_('Specified quantity exceeds stock quantity')] - }) - - if item['item'].take_stock(item['quantity'], request.user, notes=self.notes): - n += 1 - - return Response({"success": "Removed stock for {n} items".format(n=n)}) + serializer_class = StockSerializers.StockRemoveSerializer -class StockTransfer(StockAdjust): +class StockTransfer(StockAdjustView): """ API endpoint for performing stock movements """ - allow_missing_quantity = True - - def post(self, request, *args, **kwargs): - - data = request.data - - try: - location = StockLocation.objects.get(pk=data.get('location', None)) - except (ValueError, StockLocation.DoesNotExist): - raise ValidationError({'location': [_('Valid location must be specified')]}) - - n = 0 - - self.get_items(request) - - for item in self.items: - - if item['quantity'] > item['item'].quantity: - raise ValidationError({ - item['item'].pk: [_('Specified quantity exceeds stock quantity')] - }) - - # If quantity is not specified, move the entire stock - if item['quantity'] in [0, None]: - item['quantity'] = item['item'].quantity - - if item['item'].move(location, self.notes, request.user, quantity=item['quantity']): - n += 1 - - return Response({'success': _('Moved {n} parts to {loc}').format( - n=n, - loc=str(location), - )}) + serializer_class = StockSerializers.StockTransferSerializer class StockLocationList(generics.ListCreateAPIView): - """ API endpoint for list view of StockLocation objects: + """ + API endpoint for list view of StockLocation objects: - GET: Return list of StockLocation objects - POST: Create a new StockLocation """ queryset = StockLocation.objects.all() - serializer_class = LocationSerializer + serializer_class = StockSerializers.LocationSerializer def filter_queryset(self, queryset): """ @@ -514,7 +384,7 @@ class StockList(generics.ListCreateAPIView): - POST: Create a new StockItem """ - serializer_class = StockItemSerializer + serializer_class = StockSerializers.StockItemSerializer queryset = StockItem.objects.all() filterset_class = StockFilter @@ -636,7 +506,7 @@ class StockList(generics.ListCreateAPIView): # Serialize each StockLocation object for location in locations: - location_map[location.pk] = LocationBriefSerializer(location).data + location_map[location.pk] = StockSerializers.LocationBriefSerializer(location).data # Now update each StockItem with the related StockLocation data for stock_item in data: @@ -662,7 +532,7 @@ class StockList(generics.ListCreateAPIView): queryset = super().get_queryset(*args, **kwargs) - queryset = StockItemSerializer.annotate_queryset(queryset) + queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset) # Do not expose StockItem objects which are scheduled for deletion queryset = queryset.filter(scheduled_for_deletion=False) @@ -670,14 +540,14 @@ class StockList(generics.ListCreateAPIView): return queryset def filter_queryset(self, queryset): + """ + Custom filtering for the StockItem queryset + """ params = self.request.query_params queryset = super().filter_queryset(queryset) - # Perform basic filtering: - # Note: We do not let DRF filter here, it be slow AF - supplier_part = params.get('supplier_part', None) if supplier_part: @@ -818,7 +688,7 @@ class StockList(generics.ListCreateAPIView): if loc_id is not None: # Filter by 'null' location (i.e. top-level items) - if isNull(loc_id): + if isNull(loc_id) and not cascade: queryset = queryset.filter(location=None) else: try: @@ -843,6 +713,18 @@ class StockList(generics.ListCreateAPIView): except (ValueError, PartCategory.DoesNotExist): raise ValidationError({"category": "Invalid category id specified"}) + # Does the client wish to filter by BomItem + bom_item_id = params.get('bom_item', None) + + if bom_item_id is not None: + try: + bom_item = BomItem.objects.get(pk=bom_item_id) + + queryset = queryset.filter(bom_item.get_stock_filter()) + + except (ValueError, BomItem.DoesNotExist): + pass + # Filter by StockItem status status = params.get('status', None) @@ -939,7 +821,7 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin): """ queryset = StockItemAttachment.objects.all() - serializer_class = StockItemAttachmentSerializer + serializer_class = StockSerializers.StockItemAttachmentSerializer filter_backends = [ DjangoFilterBackend, @@ -958,7 +840,7 @@ class StockAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix """ queryset = StockItemAttachment.objects.all() - serializer_class = StockItemAttachmentSerializer + serializer_class = StockSerializers.StockItemAttachmentSerializer class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView): @@ -967,7 +849,7 @@ class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView): """ queryset = StockItemTestResult.objects.all() - serializer_class = StockItemTestResultSerializer + serializer_class = StockSerializers.StockItemTestResultSerializer class StockItemTestResultList(generics.ListCreateAPIView): @@ -976,7 +858,7 @@ class StockItemTestResultList(generics.ListCreateAPIView): """ queryset = StockItemTestResult.objects.all() - serializer_class = StockItemTestResultSerializer + serializer_class = StockSerializers.StockItemTestResultSerializer filter_backends = [ DjangoFilterBackend, @@ -1024,7 +906,7 @@ class StockTrackingDetail(generics.RetrieveAPIView): """ queryset = StockItemTracking.objects.all() - serializer_class = StockTrackingSerializer + serializer_class = StockSerializers.StockTrackingSerializer class StockTrackingList(generics.ListAPIView): @@ -1037,7 +919,7 @@ class StockTrackingList(generics.ListAPIView): """ queryset = StockItemTracking.objects.all() - serializer_class = StockTrackingSerializer + serializer_class = StockSerializers.StockTrackingSerializer def get_serializer(self, *args, **kwargs): try: @@ -1073,7 +955,7 @@ class StockTrackingList(generics.ListAPIView): if 'location' in deltas: try: location = StockLocation.objects.get(pk=deltas['location']) - serializer = LocationSerializer(location) + serializer = StockSerializers.LocationSerializer(location) deltas['location_detail'] = serializer.data except: pass @@ -1082,7 +964,7 @@ class StockTrackingList(generics.ListAPIView): if 'stockitem' in deltas: try: stockitem = StockItem.objects.get(pk=deltas['stockitem']) - serializer = StockItemSerializer(stockitem) + serializer = StockSerializers.StockItemSerializer(stockitem) deltas['stockitem_detail'] = serializer.data except: pass @@ -1164,7 +1046,7 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView): """ queryset = StockLocation.objects.all() - serializer_class = LocationSerializer + serializer_class = StockSerializers.LocationSerializer stock_api_urls = [ diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 535321ca80..c44dffe94f 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -2,27 +2,29 @@ JSON serializers for Stock app """ -from rest_framework import serializers +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from decimal import Decimal +from datetime import datetime, timedelta +from django.db import transaction from django.utils.translation import ugettext_lazy as _ +from django.db.models.functions import Coalesce +from django.db.models import Case, When, Value +from django.db.models import BooleanField +from django.db.models import Q + +from rest_framework import serializers +from rest_framework.serializers import ValidationError + +from sql_util.utils import SubquerySum, SubqueryCount from .models import StockItem, StockLocation from .models import StockItemTracking from .models import StockItemAttachment from .models import StockItemTestResult -from django.db.models.functions import Coalesce - -from django.db.models import Case, When, Value -from django.db.models import BooleanField -from django.db.models import Q - -from sql_util.utils import SubquerySum, SubqueryCount - -from decimal import Decimal - -from datetime import datetime, timedelta - import common.models from common.settings import currency_code_default, currency_code_mappings @@ -64,6 +66,7 @@ class StockItemSerializerBrief(InvenTreeModelSerializer): 'location', 'location_name', 'quantity', + 'serial', ] @@ -395,3 +398,196 @@ class StockTrackingSerializer(InvenTreeModelSerializer): 'label', 'tracking_type', ] + + +class StockAdjustmentItemSerializer(serializers.Serializer): + """ + Serializer for a single StockItem within a stock adjument request. + + Fields: + - item: StockItem object + - quantity: Numerical quantity + """ + + class Meta: + fields = [ + 'item', + 'quantity' + ] + + pk = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label='stock_item', + help_text=_('StockItem primary key value') + ) + + quantity = serializers.DecimalField( + max_digits=15, + decimal_places=5, + min_value=0, + required=True + ) + + +class StockAdjustmentSerializer(serializers.Serializer): + """ + Base class for managing stock adjustment actions via the API + """ + + class Meta: + fields = [ + 'items', + 'notes', + ] + + items = StockAdjustmentItemSerializer(many=True) + + notes = serializers.CharField( + required=False, + allow_blank=True, + label=_("Notes"), + help_text=_("Stock transaction notes"), + ) + + def validate(self, data): + + super().validate(data) + + items = data.get('items', []) + + if len(items) == 0: + raise ValidationError(_("A list of stock items must be provided")) + + return data + + +class StockCountSerializer(StockAdjustmentSerializer): + """ + Serializer for counting stock items + """ + + def save(self): + + request = self.context['request'] + + data = self.validated_data + items = data['items'] + notes = data.get('notes', '') + + with transaction.atomic(): + for item in items: + + stock_item = item['pk'] + quantity = item['quantity'] + + stock_item.stocktake( + quantity, + request.user, + notes=notes + ) + + +class StockAddSerializer(StockAdjustmentSerializer): + """ + Serializer for adding stock to stock item(s) + """ + + def save(self): + + request = self.context['request'] + + data = self.validated_data + notes = data.get('notes', '') + + with transaction.atomic(): + for item in data['items']: + + stock_item = item['pk'] + quantity = item['quantity'] + + stock_item.add_stock( + quantity, + request.user, + notes=notes + ) + + +class StockRemoveSerializer(StockAdjustmentSerializer): + """ + Serializer for removing stock from stock item(s) + """ + + def save(self): + + request = self.context['request'] + + data = self.validated_data + notes = data.get('notes', '') + + with transaction.atomic(): + for item in data['items']: + + stock_item = item['pk'] + quantity = item['quantity'] + + stock_item.take_stock( + quantity, + request.user, + notes=notes + ) + + +class StockTransferSerializer(StockAdjustmentSerializer): + """ + Serializer for transferring (moving) stock item(s) + """ + + location = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Location'), + help_text=_('Destination stock location'), + ) + + class Meta: + fields = [ + 'items', + 'notes', + 'location', + ] + + def validate(self, data): + + super().validate(data) + + # TODO: Any specific validation of location field? + + return data + + def save(self): + + request = self.context['request'] + + data = self.validated_data + + items = data['items'] + notes = data.get('notes', '') + location = data['location'] + + with transaction.atomic(): + for item in items: + + stock_item = item['pk'] + quantity = item['quantity'] + + stock_item.move( + location, + notes, + request.user, + quantity=quantity + ) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 759732fe6e..3addeacde2 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -561,7 +561,7 @@ function itemAdjust(action) { { success: function(item) { adjustStock(action, [item], { - onSuccess: function() { + success: function() { location.reload(); } }); diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 9a5aeb6a7e..3afaf45635 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -287,7 +287,7 @@ { success: function(items) { adjustStock(action, items, { - onSuccess: function() { + success: function() { location.reload(); } }); diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 21c355fae2..d07c35aaf7 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -513,31 +513,34 @@ class StocktakeTest(StockAPITestCase): # POST with a valid action response = self.post(url, data) - self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST) + + self.assertIn("This field is required", str(response.data["items"])) data['items'] = [{ 'no': 'aa' }] # POST without a PK - response = self.post(url, data) - self.assertContains(response, 'must contain a valid integer primary-key', status_code=status.HTTP_400_BAD_REQUEST) + response = self.post(url, data, expected_code=400) + + self.assertIn('This field is required', str(response.data)) # POST with an invalid PK data['items'] = [{ 'pk': 10 }] - response = self.post(url, data) - self.assertContains(response, 'does not match valid stock item', status_code=status.HTTP_400_BAD_REQUEST) + response = self.post(url, data, expected_code=400) + + self.assertContains(response, 'object does not exist', status_code=status.HTTP_400_BAD_REQUEST) # POST with missing quantity value data['items'] = [{ 'pk': 1234 }] - response = self.post(url, data) - self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST) + response = self.post(url, data, expected_code=400) + self.assertContains(response, 'This field is required', status_code=status.HTTP_400_BAD_REQUEST) # POST with an invalid quantity value data['items'] = [{ @@ -546,7 +549,7 @@ class StocktakeTest(StockAPITestCase): }] response = self.post(url, data) - self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains(response, 'A valid number is required', status_code=status.HTTP_400_BAD_REQUEST) data['items'] = [{ 'pk': 1234, @@ -554,18 +557,7 @@ class StocktakeTest(StockAPITestCase): }] response = self.post(url, data) - self.assertContains(response, 'must not be less than zero', status_code=status.HTTP_400_BAD_REQUEST) - - # Test with a single item - data = { - 'item': { - 'pk': 1234, - 'quantity': '10', - } - } - - response = self.post(url, data) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertContains(response, 'Ensure this value is greater than or equal to 0', status_code=status.HTTP_400_BAD_REQUEST) def test_transfer(self): """ @@ -573,24 +565,27 @@ class StocktakeTest(StockAPITestCase): """ data = { - 'item': { - 'pk': 1234, - 'quantity': 10, - }, + 'items': [ + { + 'pk': 1234, + 'quantity': 10, + } + ], 'location': 1, 'notes': "Moving to a new location" } url = reverse('api-stock-transfer') - response = self.post(url, data) - self.assertContains(response, "Moved 1 parts to", status_code=status.HTTP_200_OK) + # This should succeed + response = self.post(url, data, expected_code=201) # Now try one which will fail due to a bad location data['location'] = 'not a location' - response = self.post(url, data) - self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST) + response = self.post(url, data, expected_code=400) + + self.assertContains(response, 'Incorrect type. Expected pk value', status_code=status.HTTP_400_BAD_REQUEST) class StockItemDeletionTest(StockAPITestCase): diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 2f602a93e1..eb5fabcc25 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -7,7 +7,7 @@ from __future__ import unicode_literals from django.core.exceptions import ValidationError from django.views.generic.edit import FormMixin -from django.views.generic import DetailView, ListView, UpdateView +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 @@ -145,29 +145,6 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView): return super().get(request, *args, **kwargs) -class StockItemNotes(InvenTreeRoleMixin, UpdateView): - """ View for editing the 'notes' field of a StockItem object """ - - context_object_name = 'item' - template_name = 'stock/item_notes.html' - model = StockItem - - role_required = 'stock.view' - - fields = ['notes'] - - def get_success_url(self): - return reverse('stock-item-notes', kwargs={'pk': self.get_object().id}) - - def get_context_data(self, **kwargs): - - ctx = super().get_context_data(**kwargs) - - ctx['editing'] = str2bool(self.request.GET.get('edit', '')) - - return ctx - - class StockLocationEdit(AjaxUpdateView): """ View for editing details of a StockLocation. diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index d359d6cf4e..aa68b26dd4 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -4,7 +4,6 @@ /* globals buildStatusDisplay, constructForm, - getFieldByName, global_settings, imageHoverIcon, inventreeGet, @@ -20,6 +19,7 @@ */ /* exported + allocateStockToBuild, editBuildOrder, loadAllocationTable, loadBuildOrderAllocationTable, @@ -102,6 +102,7 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { */ var buildId = buildInfo.pk; + var partId = buildInfo.part; var outputId = 'untracked'; @@ -120,11 +121,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { var html = `
`; - // "Auto" allocation only works for untracked stock items - if (!output && lines > 0) { + if (lines > 0) { html += makeIconButton( - 'fa-magic icon-blue', 'button-output-auto', outputId, - '{% trans "Auto-allocate stock items to this output" %}', + 'fa-sign-in-alt icon-blue', 'button-output-auto', outputId, + '{% trans "Allocate stock items to this build output" %}', ); } @@ -136,7 +136,6 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { ); } - if (output) { // Add a button to "complete" the particular build output @@ -163,11 +162,17 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { // Add callbacks for the buttons $(panel).find(`#button-output-auto-${outputId}`).click(function() { + + var bom_items = $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('getData'); + // Launch modal dialog to perform auto-allocation - launchModalForm(`/build/${buildId}/auto-allocate/`, + allocateStockToBuild( + buildId, + partId, + bom_items, { - data: { - }, + source_location: buildInfo.source_location, + output: outputId, success: reloadTable, } ); @@ -344,18 +349,26 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { function requiredQuantity(row) { // Return the requied quantity for a given row + var quantity = 0; + if (output) { // "Tracked" parts are calculated against individual build outputs - return row.quantity * output.quantity; + quantity = row.quantity * output.quantity; } else { // "Untracked" parts are specified against the build itself - return row.quantity * buildInfo.quantity; + quantity = row.quantity * buildInfo.quantity; } + + // Store the required quantity in the row data + row.required = quantity; + + return quantity; } function sumAllocations(row) { // Calculat total allocations for a given row if (!row.allocations) { + row.allocated = 0; return 0; } @@ -365,6 +378,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { quantity += item.quantity; }); + row.allocated = quantity; + return quantity; } @@ -377,52 +392,28 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { // Primary key of the 'sub_part' var pk = $(this).attr('pk'); - // Launch form to allocate new stock against this output - launchModalForm('{% url "build-item-create" %}', { - success: reloadTable, - data: { - part: pk, - build: buildId, - install_into: outputId, - }, - secondary: [ - { - field: 'stock_item', - label: '{% trans "New Stock Item" %}', - title: '{% trans "Create new Stock Item" %}', - url: '{% url "stock-item-create" %}', - data: { - part: pk, - }, - }, + // Extract BomItem information from this row + var row = $(table).bootstrapTable('getRowByUniqueId', pk); + + if (!row) { + console.log('WARNING: getRowByUniqueId returned null'); + return; + } + + allocateStockToBuild( + buildId, + partId, + [ + row, ], - callback: [ - { - field: 'stock_item', - action: function(value) { - inventreeGet( - `/api/stock/${value}/`, {}, - { - success: function(response) { - - // How many items are actually available for the given stock item? - var available = response.quantity - response.allocated; - - var field = getFieldByName('#modal-form', 'quantity'); - - // Allocation quantity initial value - var initial = field.attr('value'); - - if (available < initial) { - field.val(available); - } - } - } - ); - } - } - ] - }); + { + source_location: buildInfo.source_location, + success: function(data) { + $(table).bootstrapTable('refresh'); + }, + output: output == null ? null : output.pk, + } + ); }); // Callback for 'buy' button @@ -623,17 +614,22 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var url = ''; - if (row.serial && row.quantity == 1) { - text = `{% trans "Serial Number" %}: ${row.serial}`; + + var serial = row.serial; + + if (row.stock_item_detail) { + serial = row.stock_item_detail.serial; + } + + if (serial && row.quantity == 1) { + text = `{% trans "Serial Number" %}: ${serial}`; } else { text = `{% trans "Quantity" %}: ${row.quantity}`; } - {% if build.status == BuildStatus.COMPLETE %} - url = `/stock/item/${row.pk}/`; - {% else %} - url = `/stock/item/${row.stock_item}/`; - {% endif %} + var pk = row.stock_item || row.pk; + + url = `/stock/item/${pk}/`; return renderLink(text, url); } @@ -680,22 +676,31 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { // Assign button callbacks to the newly created allocation buttons subTable.find('.button-allocation-edit').click(function() { var pk = $(this).attr('pk'); - launchModalForm(`/build/item/${pk}/edit/`, { - success: reloadTable, + + constructForm(`/api/build/item/${pk}/`, { + fields: { + quantity: {}, + }, + title: '{% trans "Edit Allocation" %}', + onSuccess: reloadTable, }); }); subTable.find('.button-allocation-delete').click(function() { var pk = $(this).attr('pk'); - launchModalForm(`/build/item/${pk}/delete/`, { - success: reloadTable, + + constructForm(`/api/build/item/${pk}/`, { + method: 'DELETE', + title: '{% trans "Remove Allocation" %}', + onSuccess: reloadTable, }); }); }, columns: [ { - field: 'pk', - visible: false, + visible: true, + switchable: false, + checkbox: true, }, { field: 'sub_part_detail.full_name', @@ -817,6 +822,316 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { } + +/** + * Allocate stock items to a build + * + * arguments: + * - buildId: ID / PK value for the build + * - partId: ID / PK value for the part being built + * - bom_items: A list of BomItem objects to be allocated + * + * options: + * - output: ID / PK of the associated build output (or null for untracked items) + * - source_location: ID / PK of the top-level StockLocation to take parts from (or null) + */ +function allocateStockToBuild(build_id, part_id, bom_items, options={}) { + + // ID of the associated "build output" (or null) + var output_id = options.output || null; + + var source_location = options.source_location; + + function renderBomItemRow(bom_item, quantity) { + + var pk = bom_item.pk; + var sub_part = bom_item.sub_part_detail; + + var thumb = thumbnailImage(bom_item.sub_part_detail.thumbnail); + + var delete_button = `
`; + + delete_button += makeIconButton( + 'fa-times icon-red', + 'button-row-remove', + pk, + '{% trans "Remove row" %}', + ); + + delete_button += `
`; + + var quantity_input = constructField( + `items_quantity_${pk}`, + { + type: 'decimal', + min_value: 0, + value: quantity || 0, + title: '{% trans "Specify stock allocation quantity" %}', + required: true, + }, + { + hideLabels: true, + } + ); + + var allocated_display = makeProgressBar( + bom_item.allocated, + bom_item.required, + ); + + var stock_input = constructField( + `items_stock_item_${pk}`, + { + type: 'related field', + required: 'true', + }, + { + hideLabels: true, + } + ); + + // var stock_input = constructRelatedFieldInput(`items_stock_item_${pk}`); + + var html = ` + + + ${thumb} ${sub_part.full_name} + + + ${allocated_display} + + + ${stock_input} + + + ${quantity_input} + + + ${delete_button} + + + `; + + return html; + } + + var table_entries = ''; + + for (var idx = 0; idx < bom_items.length; idx++) { + var bom_item = bom_items[idx]; + + var required = bom_item.required || 0; + var allocated = bom_item.allocated || 0; + var remaining = required - allocated; + + if (remaining < 0) { + remaining = 0; + } + + table_entries += renderBomItemRow(bom_item, remaining); + } + + if (bom_items.length == 0) { + + showAlertDialog( + '{% trans "Select Parts" %}', + '{% trans "You must select at least one part to allocate" %}', + ); + + return; + } + + var html = ``; + + // Render a "take from" input + html += constructField( + 'take_from', + { + type: 'related field', + label: '{% trans "Source Location" %}', + help_text: '{% trans "Select source location (leave blank to take from all locations)" %}', + required: false, + }, + {}, + ); + + // Create table of parts + html += ` + + + + + + + + + + + + ${table_entries} + +
{% trans "Part" %}{% trans "Allocated" %}{% trans "Stock Item" %}{% trans "Quantity" %}
+ `; + + constructForm(`/api/build/${build_id}/allocate/`, { + method: 'POST', + fields: {}, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm stock allocation" %}', + title: '{% trans "Allocate Stock Items to Build Order" %}', + afterRender: function(fields, options) { + + var take_from_field = { + name: 'take_from', + model: 'stocklocation', + api_url: '{% url "api-location-list" %}', + required: false, + type: 'related field', + value: source_location, + noResults: function(query) { + return '{% trans "No matching stock locations" %}'; + }, + }; + + // Initialize "take from" field + initializeRelatedField( + take_from_field, + null, + options, + ); + + // Initialize stock item fields + bom_items.forEach(function(bom_item) { + initializeRelatedField( + { + name: `items_stock_item_${bom_item.pk}`, + api_url: '{% url "api-stock-list" %}', + filters: { + bom_item: bom_item.pk, + in_stock: true, + part_detail: false, + location_detail: true, + }, + model: 'stockitem', + required: true, + render_part_detail: false, + render_location_detail: true, + auto_fill: true, + adjustFilters: function(filters) { + // Restrict query to the selected location + var location = getFormFieldValue( + 'take_from', + {}, + { + modal: options.modal, + } + ); + + filters.location = location; + filters.cascade = true; + + return filters; + }, + noResults: function(query) { + return '{% trans "No matching stock items" %}'; + } + }, + null, + options, + ); + }); + + // Add callback to "clear" button for take_from field + addClearCallback( + 'take_from', + take_from_field, + options, + ); + + // Add button callbacks + $(options.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(options.modal).find(`#allocation_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + + // Extract elements from the form + var data = { + items: [] + }; + + var item_pk_values = []; + + bom_items.forEach(function(item) { + + var quantity = getFormFieldValue( + `items_quantity_${item.pk}`, + {}, + { + modal: opts.modal, + }, + ); + + var stock_item = getFormFieldValue( + `items_stock_item_${item.pk}`, + {}, + { + modal: opts.modal, + } + ); + + if (quantity != null) { + data.items.push({ + bom_item: item.pk, + stock_item: stock_item, + quantity: quantity, + output: output_id, + }); + + item_pk_values.push(item.pk); + } + }); + + // Provide nested values + opts.nested = { + 'items': item_pk_values + }; + + inventreePut( + opts.url, + data, + { + method: 'POST', + success: function(response) { + // Hide the modal + $(opts.modal).modal('hide'); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr); + break; + } + } + } + ); + }, + }); +} + + + function loadBuildTable(table, options) { // Display a table of Build objects diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js index d7e8f45ca5..3e41003696 100644 --- a/InvenTree/templates/js/translated/filters.js +++ b/InvenTree/templates/js/translated/filters.js @@ -273,6 +273,11 @@ function setupFilterList(tableKey, table, target) { var element = $(target); + if (!element) { + console.log(`WARNING: setupFilterList could not find target '${target}'`); + return; + } + // One blank slate, please element.empty(); diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 0e815f8c6d..b43ce0cb2d 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -728,10 +728,17 @@ function updateFieldValues(fields, options) { } } - +/* + * Update the value of a named field + */ function updateFieldValue(name, value, field, options) { var el = $(options.modal).find(`#id_${name}`); + if (!el) { + console.log(`WARNING: updateFieldValue could not find field '${name}'`); + return; + } + switch (field.type) { case 'boolean': el.prop('checked', value); @@ -864,6 +871,78 @@ function clearFormErrors(options) { $(options.modal).find('#non-field-errors').html(''); } +/* + * Display form error messages as returned from the server, + * specifically for errors returned in an array. + * + * We need to know the unique ID of each item in the array, + * and the array length must equal the length of the array returned from the server + * + * arguments: + * - response: The JSON error response from the server + * - parent: The name of the parent field e.g. "items" + * - options: The global options struct + * + * options: + * - nested: A map of nested ID values for the "parent" field + * e.g. + * { + * "items": [ + * 1, + * 2, + * 12 + * ] + * } + * + */ + +function handleNestedErrors(errors, field_name, options) { + + var error_list = errors[field_name]; + + // Ignore null or empty list + if (!error_list) { + return; + } + + var nest_list = nest_list = options['nested'][field_name]; + + // Nest list must be provided! + if (!nest_list) { + console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`); + return; + } + + for (var idx = 0; idx < error_list.length; idx++) { + + var error_item = error_list[idx]; + + if (idx >= nest_list.length) { + console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`); + break; + } + + // Extract the particular ID of the nested item + var nest_id = nest_list[idx]; + + // Here, error_item is a map of field names to error messages + for (sub_field_name in error_item) { + var errors = error_item[sub_field_name]; + + // Find the target (nested) field + var target = `${field_name}_${sub_field_name}_${nest_id}`; + + for (var ii = errors.length-1; ii >= 0; ii--) { + + var error_text = errors[ii]; + + addFieldErrorMessage(target, error_text, ii, options); + } + } + } +} + + /* * Display form error messages as returned from the server. @@ -913,28 +992,30 @@ function handleFormErrors(errors, fields, options) { for (var field_name in errors) { - // Add the 'has-error' class - $(options.modal).find(`#div_id_${field_name}`).addClass('has-error'); + if (field_name in fields) { - var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`); + var field = fields[field_name]; - var field_errors = errors[field_name]; + if ((field.type == 'field') && ('child' in field)) { + // This is a "nested" field + handleNestedErrors(errors, field_name, options); + } else { + // This is a "simple" field - if (field_errors && !first_error_field && isFieldVisible(field_name, options)) { - first_error_field = field_name; - } + var field_errors = errors[field_name]; - // Add an entry for each returned error message - for (var ii = field_errors.length-1; ii >= 0; ii--) { + if (field_errors && !first_error_field && isFieldVisible(field_name, options)) { + first_error_field = field_name; + } - var error_text = field_errors[ii]; + // Add an entry for each returned error message + for (var ii = field_errors.length-1; ii >= 0; ii--) { - var error_html = ` - - ${error_text} - `; + var error_text = field_errors[ii]; - field_dom.append(error_html); + addFieldErrorMessage(field_name, error_text, ii, options); + } + } } } @@ -952,6 +1033,30 @@ function handleFormErrors(errors, fields, options) { } +/* + * Add a rendered error message to the provided field + */ +function addFieldErrorMessage(field_name, error_text, error_idx, options) { + + // Add the 'has-error' class + $(options.modal).find(`#div_id_${field_name}`).addClass('has-error'); + + var field_dom = $(options.modal).find(`#errors-${field_name}`); + + if (field_dom) { + + var error_html = ` + + ${error_text} + `; + + field_dom.append(error_html); + } else { + console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}`); + } +} + + function isFieldVisible(field, options) { return $(options.modal).find(`#div_id_${field}`).is(':visible'); @@ -1007,7 +1112,14 @@ function addClearCallbacks(fields, options) { function addClearCallback(name, field, options) { - $(options.modal).find(`#clear_${name}`).click(function() { + var el = $(options.modal).find(`#clear_${name}`); + + if (!el) { + console.log(`WARNING: addClearCallback could not find field '${name}'`); + return; + } + + el.click(function() { updateFieldValue(name, null, field, options); }); } @@ -1168,7 +1280,7 @@ function addSecondaryModal(field, fields, options) { /* - * Initializea single related-field + * Initialize a single related-field * * argument: * - modal: DOM identifier for the modal window @@ -1182,7 +1294,7 @@ function initializeRelatedField(field, fields, options) { if (!field.api_url) { // TODO: Provide manual api_url option? - console.log(`Related field '${name}' missing 'api_url' parameter.`); + console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`); return; } @@ -1203,6 +1315,15 @@ function initializeRelatedField(field, fields, options) { placeholder: '', dropdownParent: $(options.modal), dropdownAutoWidth: false, + language: { + noResults: function(query) { + if (field.noResults) { + return field.noResults(query); + } else { + return '{% trans "No results found" %}'; + } + } + }, ajax: { url: field.api_url, dataType: 'json', @@ -1225,6 +1346,11 @@ function initializeRelatedField(field, fields, options) { query.search = params.term; query.offset = offset; query.limit = pageSize; + + // Allow custom run-time filter augmentation + if ('adjustFilters' in field) { + query = field.adjustFilters(query); + } return query; }, @@ -1319,6 +1445,7 @@ function initializeRelatedField(field, fields, options) { // If a 'value' is already defined, grab the model info from the server if (field.value) { + var pk = field.value; var url = `${field.api_url}/${pk}/`.replace('//', '/'); @@ -1327,6 +1454,24 @@ function initializeRelatedField(field, fields, options) { setRelatedFieldData(name, data, options); } }); + } else if (field.auto_fill) { + // Attempt to auto-fill the field + + var filters = field.filters || {}; + + // Enforce pagination, limit to a single return (for fast query) + filters.limit = 1; + filters.offset = 0; + + inventreeGet(field.api_url, field.filters || {}, { + success: function(data) { + + // Only a single result is available, given the provided filters + if (data.count == 1) { + setRelatedFieldData(name, data.results[0], options); + } + } + }); } } @@ -1370,6 +1515,7 @@ function initializeChoiceField(field, fields, options) { select.select2({ dropdownAutoWidth: false, dropdownParent: $(options.modal), + width: '100%', }); } @@ -1884,7 +2030,7 @@ function constructChoiceInput(name, parameters) { */ function constructRelatedFieldInput(name) { - var html = ``; + var html = ``; // Don't load any options - they will be filled via an AJAX request diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 6e3f7f0c95..164452952d 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -65,7 +65,7 @@ function imageHoverIcon(url) { function thumbnailImage(url) { if (!url) { - url = '/static/img/blank_img.png'; + url = blankImage(); } // TODO: Support insertion of custom classes diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 389d5a650f..0c3dabc27e 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -37,7 +37,7 @@ function renderCompany(name, data, parameters, options) { html += `${data.name} - ${data.description}`; - html += `{% trans "Company ID" %}: ${data.pk}`; + html += `{% trans "Company ID" %}: ${data.pk}`; return html; } @@ -47,22 +47,59 @@ function renderCompany(name, data, parameters, options) { // eslint-disable-next-line no-unused-vars function renderStockItem(name, data, parameters, options) { - var image = data.part_detail.thumbnail || data.part_detail.image || blankImage(); - - var html = ``; - - html += ` ${data.part_detail.full_name || data.part_detail.name}`; - - if (data.serial && data.quantity == 1) { - html += ` - {% trans "Serial Number" %}: ${data.serial}`; - } else { - html += ` - {% trans "Quantity" %}: ${data.quantity}`; + var image = blankImage(); + + if (data.part_detail) { + image = data.part_detail.thumbnail || data.part_detail.image || blankImage(); } - if (data.part_detail.description) { + var html = ''; + + var render_part_detail = true; + + if ('render_part_detail' in parameters) { + render_part_detail = parameters['render_part_detail']; + } + + if (render_part_detail) { + html += ``; + html += ` ${data.part_detail.full_name || data.part_detail.name}`; + } + + html += ''; + + if (data.serial && data.quantity == 1) { + html += `{% trans "Serial Number" %}: ${data.serial}`; + } else { + html += `{% trans "Quantity" %}: ${data.quantity}`; + } + + html += ''; + + if (render_part_detail && data.part_detail.description) { html += `

${data.part_detail.description}

`; } + var render_stock_id = true; + + if ('render_stock_id' in parameters) { + render_stock_id = parameters['render_stock_id']; + } + + if (render_stock_id) { + html += `{% trans "Stock ID" %}: ${data.pk}`; + } + + var render_location_detail = false; + + if ('render_location_detail' in parameters) { + render_location_detail = parameters['render_location_detail']; + } + + if (render_location_detail && data.location_detail) { + html += ` - ${data.location_detail.name}`; + } + return html; } @@ -75,11 +112,17 @@ function renderStockLocation(name, data, parameters, options) { var html = `${level}${data.pathstring}`; - if (data.description) { + var render_description = true; + + if ('render_description' in parameters) { + render_description = parameters['render_description']; + } + + if (render_description && data.description) { html += ` - ${data.description}`; } - html += `{% trans "Location ID" %}: ${data.pk}`; + html += `{% trans "Location ID" %}: ${data.pk}`; return html; } @@ -96,7 +139,7 @@ function renderBuild(name, data, parameters, options) { var html = select2Thumbnail(image); html += `${data.reference} - ${data.quantity} x ${data.part_detail.full_name}`; - html += `{% trans "Build ID" %}: ${data.pk}`; + html += `{% trans "Build ID" %}: ${data.pk}`; html += `

${data.title}

`; @@ -116,7 +159,7 @@ function renderPart(name, data, parameters, options) { html += ` - ${data.description}`; } - html += `{% trans "Part ID" %}: ${data.pk}`; + html += `{% trans "Part ID" %}: ${data.pk}`; return html; } @@ -168,7 +211,7 @@ function renderPartCategory(name, data, parameters, options) { html += ` - ${data.description}`; } - html += `{% trans "Category ID" %}: ${data.pk}`; + html += `{% trans "Category ID" %}: ${data.pk}`; return html; } @@ -205,7 +248,7 @@ function renderManufacturerPart(name, data, parameters, options) { html += ` ${data.manufacturer_detail.name} - ${data.MPN}`; html += ` - ${data.part_detail.full_name}`; - html += `{% trans "Manufacturer Part ID" %}: ${data.pk}`; + html += `{% trans "Manufacturer Part ID" %}: ${data.pk}`; return html; } @@ -234,7 +277,7 @@ function renderSupplierPart(name, data, parameters, options) { html += ` ${data.supplier_detail.name} - ${data.SKU}`; html += ` - ${data.part_detail.full_name}`; - html += `{% trans "Supplier Part ID" %}: ${data.pk}`; + html += `{% trans "Supplier Part ID" %}: ${data.pk}`; return html; diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 532ab81655..43d4b56936 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -12,6 +12,7 @@ loadTableFilters, makeIconBadge, purchaseOrderStatusDisplay, + receivePurchaseOrderItems, renderLink, salesOrderStatusDisplay, setupFilterList, @@ -234,6 +235,291 @@ function newPurchaseOrderFromOrderWizard(e) { }); } + +/** + * Receive stock items against a PurchaseOrder + * Uses the POReceive API endpoint + * + * arguments: + * - order_id, ID / PK for the PurchaseOrder instance + * - line_items: A list of PurchaseOrderLineItems objects to be allocated + * + * options: + * - + */ +function receivePurchaseOrderItems(order_id, line_items, options={}) { + + if (line_items.length == 0) { + showAlertDialog( + '{% trans "Select Line Items" %}', + '{% trans "At least one line item must be selected" %}', + ); + return; + } + + function renderLineItem(line_item, opts={}) { + + var pk = line_item.pk; + + // Part thumbnail + description + var thumb = thumbnailImage(line_item.part_detail.thumbnail); + + var quantity = (line_item.quantity || 0) - (line_item.received || 0); + + if (quantity < 0) { + quantity = 0; + } + + // Quantity to Receive + var quantity_input = constructField( + `items_quantity_${pk}`, + { + type: 'decimal', + min_value: 0, + value: quantity, + title: '{% trans "Quantity to receive" %}', + required: true, + }, + { + hideLabels: true, + } + ); + + // Construct list of StockItem status codes + var choices = []; + + for (var key in stockCodes) { + choices.push({ + value: key, + display_name: stockCodes[key].value, + }); + } + + var destination_input = constructField( + `items_location_${pk}`, + { + type: 'related field', + label: '{% trans "Location" %}', + required: false, + }, + { + hideLabels: true, + } + ); + + var status_input = constructField( + `items_status_${pk}`, + { + type: 'choice', + label: '{% trans "Stock Status" %}', + required: true, + choices: choices, + value: 10, // OK + }, + { + hideLabels: true, + } + ); + + // Button to remove the row + var delete_button = `
`; + + delete_button += makeIconButton( + 'fa-times icon-red', + 'button-row-remove', + pk, + '{% trans "Remove row" %}', + ); + + delete_button += '
'; + + var html = ` + + + ${thumb} ${line_item.part_detail.full_name} + + + ${line_item.supplier_part_detail.SKU} + + + ${line_item.quantity} + + + ${line_item.received} + + + ${quantity_input} + + + ${status_input} + + + ${destination_input} + + + ${delete_button} + + `; + + return html; + } + + var table_entries = ''; + + line_items.forEach(function(item) { + table_entries += renderLineItem(item); + }); + + var html = ``; + + // Add table + html += ` + + + + + + + + + + + + + + + ${table_entries} + +
{% trans "Part" %}{% trans "Order Code" %}{% trans "Ordered" %}{% trans "Received" %}{% trans "Receive" %}{% trans "Status" %}{% trans "Destination" %}
+ `; + + constructForm(`/api/order/po/${order_id}/receive/`, { + method: 'POST', + fields: { + location: {}, + }, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm receipt of items" %}', + title: '{% trans "Receive Purchase Order Items" %}', + afterRender: function(fields, opts) { + // Initialize the "destination" field for each item + line_items.forEach(function(item) { + + var pk = item.pk; + + var name = `items_location_${pk}`; + + var field_details = { + name: name, + api_url: '{% url "api-location-list" %}', + filters: { + + }, + type: 'related field', + model: 'stocklocation', + required: false, + auto_fill: false, + value: item.destination || item.part_detail.default_location, + render_description: false, + }; + + initializeRelatedField( + field_details, + null, + opts, + ); + + addClearCallback( + name, + field_details, + opts + ); + + initializeChoiceField( + { + name: `items_status_${pk}`, + }, + null, + opts + ); + }); + + // Add callbacks to remove rows + $(opts.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(opts.modal).find(`#receive_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + // Extract data elements from the form + var data = { + items: [], + location: getFormFieldValue('location', {}, opts), + }; + + var item_pk_values = []; + + line_items.forEach(function(item) { + + var pk = item.pk; + + var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts); + + var status = getFormFieldValue(`items_status_${pk}`, {}, opts); + + var location = getFormFieldValue(`items_location_${pk}`, {}, opts); + + if (quantity != null) { + data.items.push({ + line_item: pk, + quantity: quantity, + status: status, + location: location, + }); + + item_pk_values.push(pk); + } + + }); + + // Provide list of nested values + opts.nested = { + 'items': item_pk_values, + }; + + inventreePut( + opts.url, + data, + { + method: 'POST', + success: function(response) { + // Hide the modal + $(opts.modal).modal('hide'); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr); + break; + } + } + } + ); + } + }); +} + + function editPurchaseOrderLineItem(e) { /* Edit a purchase order line item in a modal form. @@ -280,12 +566,10 @@ function loadPurchaseOrderTable(table, options) { filters[key] = options.params[key]; } - options.url = options.url || '{% url "api-po-list" %}'; - setupFilterList('purchaseorder', $(table)); $(table).inventreeTable({ - url: options.url, + url: '{% url "api-po-list" %}', queryParams: filters, name: 'purchaseorder', groupBy: false, @@ -379,6 +663,21 @@ function loadPurchaseOrderTable(table, options) { */ function loadPurchaseOrderLineItemTable(table, options={}) { + options.params = options.params || {}; + + options.params['order'] = options.order; + options.params['part_detail'] = true; + + var filters = loadTableFilters('purchaseorderlineitem'); + + for (var key in options.params) { + filters[key] = options.params[key]; + } + + var target = options.filter_target || '#filter-list-purchase-order-lines'; + + setupFilterList('purchaseorderlineitem', $(table), target); + function setupCallbacks() { if (options.allow_edit) { $(table).find('.button-line-edit').click(function() { @@ -424,22 +723,24 @@ function loadPurchaseOrderLineItemTable(table, options={}) { $(table).find('.button-line-receive').click(function() { var pk = $(this).attr('pk'); - launchModalForm(`/order/purchase-order/${options.order}/receive/`, { - success: function() { - $(table).bootstrapTable('refresh'); - }, - data: { - line: pk, - }, - secondary: [ - { - field: 'location', - label: '{% trans "New Location" %}', - title: '{% trans "Create new stock location" %}', - url: '{% url "stock-location-create" %}', - }, - ] - }); + var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); + + if (!line_item) { + console.log('WARNING: getRowByUniqueId returned null'); + return; + } + + receivePurchaseOrderItems( + options.order, + [ + line_item, + ], + { + success: function() { + $(table).bootstrapTable('refresh'); + } + } + ); }); } } @@ -451,17 +752,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) { formatNoMatches: function() { return '{% trans "No line items found" %}'; }, - queryParams: { - order: options.order, - part_detail: true - }, + queryParams: filters, + original: options.params, url: '{% url "api-po-line-list" %}', showFooter: true, + uniqueId: 'pk', columns: [ { - field: 'pk', - title: 'ID', - visible: false, + checkbox: true, + visible: true, switchable: false, }, { @@ -618,7 +917,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) { } if (options.allow_receive && row.received < row.quantity) { - html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}'); + html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}'); } html += `
`; diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 17c2598d1b..b88f5f1862 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -4,15 +4,12 @@ /* globals attachSelect, - attachToggle, - blankImage, enableField, clearField, clearFieldOptions, closeModal, + constructField, constructFormBody, - constructNumberInput, - createNewModal, getFormFieldValue, global_settings, handleFormErrors, @@ -247,7 +244,7 @@ function adjustStock(action, items, options={}) { break; } - var image = item.part_detail.thumbnail || item.part_detail.image || blankImage(); + var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image); var status = stockStatusDisplay(item.status, { classes: 'float-right' @@ -268,14 +265,18 @@ function adjustStock(action, items, options={}) { var actionInput = ''; if (actionTitle != null) { - actionInput = constructNumberInput( - item.pk, + actionInput = constructField( + `items_quantity_${pk}`, { - value: value, + type: 'decimal', min_value: minValue, max_value: maxValue, - read_only: readonly, + value: value, title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}', + required: true, + }, + { + hideLabels: true, } ); } @@ -293,7 +294,7 @@ function adjustStock(action, items, options={}) { html += ` - ${item.part_detail.full_name} + ${thumb} ${item.part_detail.full_name} ${quantity}${status} ${location} @@ -319,50 +320,89 @@ function adjustStock(action, items, options={}) { html += ``; - var modal = createNewModal({ - title: formTitle, - }); + var extraFields = {}; - // Extra fields - var extraFields = { - location: { - label: '{% trans "Location" %}', - help_text: '{% trans "Select destination stock location" %}', - type: 'related field', - required: true, - api_url: `/api/stock/location/`, - model: 'stocklocation', - name: 'location', - }, - notes: { - label: '{% trans "Notes" %}', - help_text: '{% trans "Stock transaction notes" %}', - type: 'string', - name: 'notes', - } - }; - - if (!specifyLocation) { - delete extraFields.location; + if (specifyLocation) { + extraFields.location = {}; } - constructFormBody({}, { - preFormContent: html, + if (action != 'delete') { + extraFields.notes = {}; + } + + constructForm(url, { + method: 'POST', fields: extraFields, + preFormContent: html, confirm: true, confirmMessage: '{% trans "Confirm stock adjustment" %}', - modal: modal, - onSubmit: function(fields) { + title: formTitle, + afterRender: function(fields, opts) { + // Add button callbacks to remove rows + $(opts.modal).find('.button-stock-item-remove').click(function() { + var pk = $(this).attr('pk'); - // "Delete" action gets handled differently + $(opts.modal).find(`#stock_item_${pk}`).remove(); + }); + + // Initialize "location" field + if (specifyLocation) { + initializeRelatedField( + { + name: 'location', + type: 'related field', + model: 'stocklocation', + required: true, + }, + null, + opts + ); + } + }, + onSubmit: function(fields, opts) { + + // Extract data elements from the form + var data = { + items: [], + }; + + if (action != 'delete') { + data.notes = getFormFieldValue('notes', {}, opts); + } + + if (specifyLocation) { + data.location = getFormFieldValue('location', {}, opts); + } + + var item_pk_values = []; + + items.forEach(function(item) { + var pk = item.pk; + + // Does the row exist in the form? + var row = $(opts.modal).find(`#stock_item_${pk}`); + + if (row) { + + item_pk_values.push(pk); + + var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts); + + data.items.push({ + pk: pk, + quantity: quantity, + }); + } + }); + + // Delete action is handled differently if (action == 'delete') { - var requests = []; - items.forEach(function(item) { + item_pk_values.forEach(function(pk) { requests.push( inventreeDelete( - `/api/stock/${item.pk}/`, + `/api/stock/${pk}/`, ) ); }); @@ -370,72 +410,40 @@ function adjustStock(action, items, options={}) { // Wait for *all* the requests to complete $.when.apply($, requests).done(function() { // Destroy the modal window - $(modal).modal('hide'); + $(opts.modal).modal('hide'); - if (options.onSuccess) { - options.onSuccess(); + if (options.success) { + options.success(); } }); return; } - // Data to transmit - var data = { - items: [], + opts.nested = { + 'items': item_pk_values, }; - // Add values for each selected stock item - items.forEach(function(item) { - - var q = getFormFieldValue(item.pk, {}, {modal: modal}); - - if (q != null) { - data.items.push({pk: item.pk, quantity: q}); - } - }); - - // Add in extra field data - for (var field_name in extraFields) { - data[field_name] = getFormFieldValue( - field_name, - fields[field_name], - { - modal: modal, - } - ); - } - inventreePut( url, data, { method: 'POST', - success: function() { + success: function(response) { + // Hide the modal + $(opts.modal).modal('hide'); - // Destroy the modal window - $(modal).modal('hide'); - - if (options.onSuccess) { - options.onSuccess(); + if (options.success) { + options.success(response); } }, error: function(xhr) { switch (xhr.status) { case 400: - - // Handle errors for standard fields - handleFormErrors( - xhr.responseJSON, - extraFields, - { - modal: modal, - } - ); - + handleFormErrors(xhr.responseJSON, fields, opts); break; default: - $(modal).modal('hide'); + $(opts.modal).modal('hide'); showApiError(xhr); break; } @@ -444,18 +452,6 @@ function adjustStock(action, items, options={}) { ); } }); - - // Attach callbacks for the action buttons - $(modal).find('.button-stock-item-remove').click(function() { - var pk = $(this).attr('pk'); - - $(modal).find(`#stock_item_${pk}`).remove(); - }); - - attachToggle(modal); - - $(modal + ' .select2-container').addClass('select-full-width'); - $(modal + ' .select2-container').css('width', '100%'); } @@ -1258,7 +1254,7 @@ function loadStockTable(table, options) { var items = $(table).bootstrapTable('getSelections'); adjustStock(action, items, { - onSuccess: function() { + success: function() { $(table).bootstrapTable('refresh'); } }); diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index b94bc324c7..4d12f69780 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -274,7 +274,16 @@ function getAvailableTableFilters(tableKey) { }; } - // Filters for the "Order" table + // Filters for PurchaseOrderLineItem table + if (tableKey == 'purchaseorderlineitem') { + return { + completed: { + type: 'bool', + title: '{% trans "Completed" %}', + }, + }; + } + // Filters for the PurchaseOrder table if (tableKey == 'purchaseorder') { return { diff --git a/tasks.py b/tasks.py index 1abbf23bc6..59fa83e56b 100644 --- a/tasks.py +++ b/tasks.py @@ -127,13 +127,20 @@ def worker(c): @task -def rebuild(c): +def rebuild_models(c): """ Rebuild database models with MPTT structures """ - manage(c, "rebuild_models") + manage(c, "rebuild_models", pty=True) +@task +def rebuild_thumbnails(c): + """ + Rebuild missing image thumbnails + """ + + manage(c, "rebuild_thumbnails", pty=True) @task def clean_settings(c): @@ -143,7 +150,7 @@ def clean_settings(c): manage(c, "clean_settings") -@task(post=[rebuild]) +@task(post=[rebuild_models, rebuild_thumbnails]) def migrate(c): """ Performs database migrations. @@ -341,7 +348,7 @@ def export_records(c, filename='data.json'): print("Data export completed") -@task(help={'filename': 'Input filename'}, post=[rebuild]) +@task(help={'filename': 'Input filename'}, post=[rebuild_models, rebuild_thumbnails]) def import_records(c, filename='data.json'): """ Import database records from a file @@ -399,7 +406,7 @@ def delete_data(c, force=False): manage(c, 'flush') -@task(post=[rebuild]) +@task(post=[rebuild_models, rebuild_thumbnails]) def import_fixtures(c): """ Import fixture data into the database.