diff --git a/.github/workflows/docker_stable.yaml b/.github/workflows/docker_stable.yaml index 3d435e40da..1f3c7749c7 100644 --- a/.github/workflows/docker_stable.yaml +++ b/.github/workflows/docker_stable.yaml @@ -1,4 +1,5 @@ -# Build and push latest docker image on push to master branch +# Build and push docker image on push to 'stable' branch +# Docker build will be uploaded to dockerhub with the 'inventree:stable' tag name: Docker Build diff --git a/.github/workflows/docker_tag.yaml b/.github/workflows/docker_tag.yaml index b3b0c53d12..90b8f72505 100644 --- a/.github/workflows/docker_tag.yaml +++ b/.github/workflows/docker_tag.yaml @@ -1,4 +1,5 @@ -# Publish docker images to dockerhub +# Publish docker images to dockerhub on a tagged release +# Docker build will be uploaded to dockerhub with the 'invetree:' tag name: Docker Publish diff --git a/.github/workflows/docker_test.yaml b/.github/workflows/docker_test.yaml new file mode 100644 index 0000000000..0067f337e5 --- /dev/null +++ b/.github/workflows/docker_test.yaml @@ -0,0 +1,37 @@ +# Test that the InvenTree docker image compiles correctly + +# This CI action runs on pushes to either the master or stable branches + +# 1. Build the development docker image (as per the documentation) +# 2. Install requied python libs into the docker container +# 3. Launch the container +# 4. Check that the API endpoint is available + +name: Docker Test + +on: + push: + branches: + - 'master' + - 'stable' + +jobs: + + docker: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: Build Docker Image + run: | + cd docker + docker-compose -f docker-compose.dev.yml build + docker-compose -f docker-compose.dev.yml run inventree-dev-server invoke update + docker-compose -f docker-compose.dev.yml up -d + - name: Sleepy Time + run: sleep 60 + - name: Test API + run: | + pip install requests + python3 ci/check_api_endpoint.py 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/apps.py b/InvenTree/InvenTree/apps.py index 6456c5994f..31a887d736 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -63,6 +63,12 @@ class InvenTreeConfig(AppConfig): schedule_type=Schedule.DAILY, ) + # Delete old error messages + InvenTree.tasks.schedule_task( + 'InvenTree.tasks.delete_old_error_logs', + schedule_type=Schedule.DAILY, + ) + # Delete "old" stock items InvenTree.tasks.schedule_task( 'stock.tasks.delete_old_stock_items', 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/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 585c0b3825..71e518560b 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -242,6 +242,14 @@ border-color: var(--label-red); } +.label-form { + margin: 2px; + padding: 3px; + padding-left: 10px; + padding-right: 10px; + border-radius: 5px; +} + .label-red { background: var(--label-red); } diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 5fb6960601..3889f108af 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -156,7 +156,34 @@ def delete_successful_tasks(): started__lte=threshold ) - results.delete() + if results.count() > 0: + logger.info(f"Deleting {results.count()} successful task records") + results.delete() + + +def delete_old_error_logs(): + """ + Delete old error logs from the server + """ + + try: + from error_report.models import Error + + # Delete any error logs more than 30 days old + threshold = timezone.now() - timedelta(days=30) + + errors = Error.objects.filter( + when__lte=threshold, + ) + + if errors.count() > 0: + logger.info(f"Deleting {errors.count()} old error logs") + errors.delete() + + except AppRegistryNotReady: + # Apps not yet loaded + logger.info("Could not perform 'delete_old_error_logs' - App registry not ready") + return def check_for_updates(): @@ -215,7 +242,7 @@ def delete_expired_sessions(): # Delete any sessions that expired more than a day ago expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1)) - if True or expired.count() > 0: + if expired.count() > 0: logger.info(f"Deleting {expired.count()} expired sessions.") expired.delete() @@ -247,15 +274,15 @@ def update_exchange_rates(): pass except: # Some other error - print("Database not ready") + logger.warning("update_exchange_rates: Database not ready") return backend = InvenTreeExchange() - print(f"Updating exchange rates from {backend.url}") + logger.info(f"Updating exchange rates from {backend.url}") base = currency_code_default() - print(f"Using base currency '{base}'") + logger.info(f"Using base currency '{base}'") backend.update_rates(base_currency=base) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index f309f85e66..ac3c6178ff 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,11 +10,23 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" -INVENTREE_API_VERSION = 12 +INVENTREE_API_VERSION = 15 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v15 -> 2021-10-06 + - Adds detail endpoint for SalesOrderAllocation model + - Allows use of the API forms interface for adjusting SalesOrderAllocation objects + +v14 -> 2021-10-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 @@ -96,7 +108,7 @@ def inventreeDocsVersion(): Return the version string matching the latest documentation. Development -> "latest" - Release -> "major.minor" + Release -> "major.minor.sub" e.g. "0.5.2" """ @@ -105,7 +117,7 @@ def inventreeDocsVersion(): else: major, minor, patch = inventreeVersionTuple() - return f"{major}.{minor}" + return f"{major}.{minor}.{patch}" def isInvenTreeUpToDate(): 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/common/models.py b/InvenTree/common/models.py index 700463849c..534d34eeef 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -649,14 +649,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, - # TODO: Remove this setting in future, new API forms make this not useful - 'PART_SHOW_QUANTITY_IN_FORMS': { - 'name': _('Show Quantity in Forms'), - 'description': _('Display available part quantity in some forms'), - 'default': True, - 'validator': bool, - }, - 'PART_SHOW_IMPORT': { 'name': _('Show Import in Views'), 'description': _('Display the import wizard in some part views'), @@ -671,6 +663,18 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, + # 2021-10-08 + # This setting exists as an interim solution for https://github.com/inventree/InvenTree/issues/2042 + # The BOM API can be extremely slow when calculating pricing information "on the fly" + # A future solution will solve this properly, + # but as an interim step we provide a global to enable / disable BOM pricing + 'PART_SHOW_PRICE_IN_BOM': { + 'name': _('Show Price in BOM'), + 'description': _('Include pricing information in BOM tables'), + 'default': True, + 'validator': bool, + }, + 'PART_SHOW_RELATED': { 'name': _('Show related parts'), 'description': _('Display related parts for a part'), @@ -973,6 +977,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'default': 10, 'validator': [int, MinValueValidator(1)] }, + + 'PART_SHOW_QUANTITY_IN_FORMS': { + 'name': _('Show Quantity in Forms'), + 'description': _('Display available part quantity in some forms'), + 'default': True, + 'validator': bool, + }, } class Meta: 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/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index e4ca64b32e..b05a21304d 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -158,6 +158,12 @@ function reloadImage(data) { if (data.image) { $('#company-image').attr('src', data.image); + + // Reset the "modal image" view + $('#company-image').click(function() { + showModalImage(data.image); + }); + } else { location.reload(); } diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index 5d843fc624..54e91ed844 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -77,6 +77,14 @@ class POLineItemResource(ModelResource): class SOLineItemResource(ModelResource): """ Class for managing import / export of SOLineItem data """ + part_name = Field(attribute='part__name', readonly=True) + + IPN = Field(attribute='part__IPN', readonly=True) + + description = Field(attribute='part__description', readonly=True) + + fulfilled = Field(attribute='fulfilled_quantity', readonly=True) + class Meta: model = SalesOrderLineItem skip_unchanged = True diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index ab6c4d7c0b..af30a3a5c5 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): @@ -668,6 +631,15 @@ class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = SOLineItemSerializer +class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for detali view of a SalesOrderAllocation object + """ + + queryset = SalesOrderAllocation.objects.all() + serializer_class = SalesOrderAllocationSerializer + + class SOAllocationList(generics.ListCreateAPIView): """ API endpoint for listing SalesOrderAllocation objects @@ -780,8 +752,10 @@ order_api_urls = [ ])), # API endpoints for purchase order line items - url(r'^po-line/(?P\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), - url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'), + url(r'^po-line/', include([ + url(r'^(?P\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), + url(r'^.*$', POLineItemList.as_view(), name='api-po-line-list'), + ])), # API endpoints for sales ordesr url(r'^so/', include([ @@ -801,9 +775,8 @@ order_api_urls = [ ])), # API endpoints for sales order allocations - url(r'^so-allocation', include([ - - # List all sales order allocations + url(r'^so-allocation/', include([ + url(r'^(?P\d+)/$', SOAllocationDetail.as_view(), name='api-so-allocation-detail'), url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'), ])), ] diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index e0c500e5e3..227109c46c 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, @@ -134,23 +115,6 @@ class AllocateSerialsToSalesOrderForm(forms.Form): ] -class CreateSalesOrderAllocationForm(HelperForm): - """ - Form for creating a SalesOrderAllocation item. - """ - - quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity')) - - class Meta: - model = SalesOrderAllocation - - fields = [ - 'line', - 'item', - 'quantity', - ] - - class EditSalesOrderAllocationForm(HelperForm): """ Form for editing a SalesOrderAllocation item diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 495ea2d333..4ac8925259 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -840,7 +840,13 @@ class SalesOrderLineItem(OrderLineItem): def get_api_url(): return reverse('api-so-line-list') - order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), help_text=_('Sales Order')) + order = models.ForeignKey( + SalesOrder, + on_delete=models.CASCADE, + related_name='lines', + verbose_name=_('Order'), + help_text=_('Sales Order') + ) part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True}) @@ -954,7 +960,11 @@ class SalesOrderAllocation(models.Model): if len(errors) > 0: raise ValidationError(errors) - line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), related_name='allocations') + line = models.ForeignKey( + SalesOrderLineItem, + on_delete=models.CASCADE, + verbose_name=_('Line'), + related_name='allocations') item = models.ForeignKey( 'stock.StockItem', diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index da2d23cd0d..40cd2def58 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 = [ @@ -423,7 +478,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True) order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True) serial = serializers.CharField(source='get_serial', read_only=True) - quantity = serializers.FloatField(read_only=True) + quantity = serializers.FloatField(read_only=False) location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True) # Extra detail fields @@ -494,7 +549,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer): order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True) - allocations = SalesOrderAllocationSerializer(many=True, read_only=True) + allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) quantity = serializers.FloatField() diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 0d46207c33..8b98755900 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -39,6 +39,9 @@ src="{% static 'img/blank_image.png' %}" + {% if roles.purchase_order.change %} {% elif order.status == PurchaseOrderStatus.PLACED %} {% endif %} {% endif %} -
{% endblock %} @@ -188,17 +188,27 @@ $("#edit-order").click(function() { }); $("#receive-order").click(function() { - launchModalForm("{% url 'po-receive' order.id %}", { - reload: true, - secondary: [ - { - field: 'location', - label: '{% trans "New Location" %}', - title: '{% trans "Create new stock location" %}', - url: "{% url 'stock-location-create' %}", - }, - ] + + // Auto select items which have not been fully allocated + var items = $("#po-line-table").bootstrapTable('getData'); + + var items_to_receive = []; + + items.forEach(function(item) { + if (item.received < item.quantity) { + items_to_receive.push(item); + } }); + + receivePurchaseOrderItems( + {{ order.id }}, + items_to_receive, + { + success: function() { + $("#po-line-table").bootstrapTable('refresh'); + } + } + ); }); $("#complete-order").click(function() { @@ -214,7 +224,7 @@ $("#cancel-order").click(function() { }); $("#export-order").click(function() { - location.href = "{% url 'po-export' order.id %}"; + exportOrder('{% url "po-export" order.id %}'); }); diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 4262a810e0..987ab79d58 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -18,14 +18,23 @@
- {% 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/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 6f8c422f7a..3fd34e42b9 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -50,6 +50,9 @@ src="{% static 'img/blank_image.png' %}" + {% if roles.sales_order.change %} {% endif %} {% endif %} +
{% endblock %} @@ -196,4 +201,8 @@ $('#print-order-report').click(function() { printSalesOrderReports([{{ order.pk }}]); }); +$('#export-order').click(function() { + exportOrder('{% url "so-export" order.id %}'); +}); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 30799e2296..bd853702c4 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -158,467 +158,38 @@ $("#so-lines-table").bootstrapTable("refresh"); } -$("#new-so-line").click(function() { + $("#new-so-line").click(function() { - constructForm('{% url "api-so-line-list" %}', { - fields: { - order: { - value: {{ order.pk }}, - hidden: true, - }, - part: {}, - quantity: {}, - reference: {}, - sale_price: {}, - sale_price_currency: {}, - notes: {}, - }, - method: 'POST', - title: '{% trans "Add Line Item" %}', - onSuccess: reloadTable, - }); -}); - -{% if order.status == SalesOrderStatus.PENDING %} -function showAllocationSubTable(index, row, element) { - // Construct a table showing stock items which have been allocated against this line item - - var html = `
`; - - element.html(html); - - var lineItem = row; - - var table = $(`#allocation-table-${row.pk}`); - - table.bootstrapTable({ - data: row.allocations, - showHeader: false, - columns: [ - { - width: '50%', - field: 'allocated', - title: '{% trans "Quantity" %}', - formatter: function(value, row, index, field) { - var text = ''; - - if (row.serial != null && row.quantity == 1) { - text = `{% trans "Serial Number" %}: ${row.serial}`; - } else { - text = `{% trans "Quantity" %}: ${row.quantity}`; - } - - return renderLink(text, `/stock/item/${row.item}/`); - }, - }, - { - field: 'location', - title: 'Location', - formatter: function(value, row, index, field) { - return renderLink(row.location_path, `/stock/location/${row.location}/`); - }, - }, - { - field: 'po' - }, - { - field: 'buttons', - title: '{% trans "Actions" %}', - formatter: function(value, row, index, field) { - - var html = "
"; - var pk = row.pk; - - {% if order.status == SalesOrderStatus.PENDING %} - html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); - {% endif %} - - html += "
"; - - return html; - }, - }, - ], - }); - - table.find(".button-allocation-edit").click(function() { - - var pk = $(this).attr('pk'); - - launchModalForm(`/order/sales-order/allocation/${pk}/edit/`, { - success: reloadTable, - }); - }); - - table.find(".button-allocation-delete").click(function() { - var pk = $(this).attr('pk'); - - launchModalForm(`/order/sales-order/allocation/${pk}/delete/`, { - success: reloadTable, - }); - }); -} -{% endif %} - -function showFulfilledSubTable(index, row, element) { - // Construct a table showing stock items which have been fulfilled against this line item - - var id = `fulfilled-table-${row.pk}`; - var html = `
`; - - element.html(html); - - var lineItem = row; - - $(`#${id}`).bootstrapTable({ - url: "{% url 'api-stock-list' %}", - queryParams: { - part: row.part, - sales_order: {{ order.id }}, - }, - showHeader: false, - columns: [ - { - field: 'pk', - visible: false, - }, - { - field: 'stock', - formatter: function(value, row) { - var text = ''; - if (row.serial && row.quantity == 1) { - text = `{% trans "Serial Number" %}: ${row.serial}`; - } else { - text = `{% trans "Quantity" %}: ${row.quantity}`; - } - - return renderLink(text, `/stock/item/${row.pk}/`); - }, - }, - { - field: 'po' - }, - ], - }); -} - -$("#so-lines-table").inventreeTable({ - formatNoMatches: function() { return "{% trans 'No matching line items' %}"; }, - queryParams: { - order: {{ order.id }}, - part_detail: true, - allocations: true, - }, - sidePagination: 'server', - uniqueId: 'pk', - url: "{% url 'api-so-line-list' %}", - onPostBody: setupCallbacks, - {% if order.status == SalesOrderStatus.PENDING or order.status == SalesOrderStatus.SHIPPED %} - detailViewByClick: true, - detailView: true, - detailFilter: function(index, row) { - {% if order.status == SalesOrderStatus.PENDING %} - return row.allocated > 0; - {% else %} - return row.fulfilled > 0; - {% endif %} - }, - {% if order.status == SalesOrderStatus.PENDING %} - detailFormatter: showAllocationSubTable, - {% else %} - detailFormatter: showFulfilledSubTable, - {% endif %} - {% endif %} - showFooter: true, - columns: [ - { - field: 'pk', - title: '{% trans "ID" %}', - visible: false, - switchable: false, - }, - { - sortable: true, - sortName: 'part__name', - field: 'part', - title: '{% trans "Part" %}', - formatter: function(value, row, index, field) { - if (row.part) { - return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`); - } else { - return '-'; - } - }, - footerFormatter: function() { - return '{% trans "Total" %}' - }, - }, - { - sortable: true, - field: 'reference', - title: '{% trans "Reference" %}' - }, - { - sortable: true, - field: 'quantity', - title: '{% trans "Quantity" %}', - footerFormatter: function(data) { - return data.map(function (row) { - return +row['quantity'] - }).reduce(function (sum, i) { - return sum + i - }, 0) - }, - }, - { - sortable: true, - field: 'sale_price', - title: '{% trans "Unit Price" %}', - formatter: function(value, row) { - return row.sale_price_string || row.sale_price; - } - }, - { - sortable: true, - title: '{% trans "Total price" %}', - formatter: function(value, row) { - var total = row.sale_price * row.quantity; - var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.sale_price_currency}); - return formatter.format(total) - }, - footerFormatter: function(data) { - var total = data.map(function (row) { - return +row['sale_price']*row['quantity'] - }).reduce(function (sum, i) { - return sum + i - }, 0) - var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD'; - var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency}); - return formatter.format(total) - } - }, - - { - field: 'allocated', - {% if order.status == SalesOrderStatus.PENDING %} - title: '{% trans "Allocated" %}', - {% else %} - title: '{% trans "Fulfilled" %}', - {% endif %} - formatter: function(value, row, index, field) { - {% if order.status == SalesOrderStatus.PENDING %} - var quantity = row.allocated; - {% else %} - var quantity = row.fulfilled; - {% endif %} - return makeProgressBar(quantity, row.quantity, { - id: `order-line-progress-${row.pk}`, - }); - }, - sorter: function(valA, valB, rowA, rowB) { - {% if order.status == SalesOrderStatus.PENDING %} - var A = rowA.allocated; - var B = rowB.allocated; - {% else %} - var A = rowA.fulfilled; - var B = rowB.fulfilled; - {% endif %} - - if (A == 0 && B == 0) { - return (rowA.quantity > rowB.quantity) ? 1 : -1; - } - - var progressA = parseFloat(A) / rowA.quantity; - var progressB = parseFloat(B) / rowB.quantity; - - return (progressA < progressB) ? 1 : -1; - } - }, - { - field: 'notes', - title: '{% trans "Notes" %}', - }, - { - field: 'po', - title: '{% trans "PO" %}', - formatter: function(value, row, index, field) { - var po_name = ""; - if (row.allocated) { - row.allocations.forEach(function(allocation) { - if (allocation.po != po_name) { - if (po_name) { - po_name = "-"; - } else { - po_name = allocation.po - } - } - }) - } - return `
` + po_name + `
`; - } - }, - {% if order.status == SalesOrderStatus.PENDING %} - { - field: 'buttons', - formatter: function(value, row, index, field) { - - var html = `
`; - - var pk = row.pk; - - if (row.part) { - var part = row.part_detail; - - if (part.trackable) { - html += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}'); - } - - html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}'); - - if (part.purchaseable) { - html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}'); - } - - if (part.assembly) { - html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}'); - } - - html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}'); - } - - html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}'); - - html += `
`; - - return html; - } - }, - {% endif %} - ], -}); - -function setupCallbacks() { - - var table = $("#so-lines-table"); - - // Set up callbacks for the row buttons - table.find(".button-edit").click(function() { - - var pk = $(this).attr('pk'); - - constructForm(`/api/order/so-line/${pk}/`, { + constructForm('{% url "api-so-line-list" %}', { fields: { + order: { + value: {{ order.pk }}, + hidden: true, + }, + part: {}, quantity: {}, reference: {}, sale_price: {}, sale_price_currency: {}, notes: {}, }, - title: '{% trans "Edit Line Item" %}', + method: 'POST', + title: '{% trans "Add Line Item" %}', onSuccess: reloadTable, }); }); - table.find(".button-delete").click(function() { - var pk = $(this).attr('pk'); - - constructForm(`/api/order/so-line/${pk}/`, { - method: 'DELETE', - title: '{% trans "Delete Line Item" %}', - onSuccess: reloadTable, - }); - }); - - table.find(".button-add-by-sn").click(function() { - var pk = $(this).attr('pk'); - - inventreeGet(`/api/order/so-line/${pk}/`, {}, - { - success: function(response) { - launchModalForm('{% url "so-assign-serials" %}', { - success: reloadTable, - data: { - line: pk, - part: response.part, - } - }); - } - } - ); - }); - - table.find(".button-add").click(function() { - var pk = $(this).attr('pk'); - - launchModalForm(`/order/sales-order/allocation/new/`, { - success: reloadTable, - data: { - line: pk, - }, - }); - }); - - table.find(".button-build").click(function() { - - var pk = $(this).attr('pk'); - - // Extract the row data from the table! - var idx = $(this).closest('tr').attr('data-index'); - - var row = table.bootstrapTable('getData')[idx]; - - var quantity = 1; - - if (row.allocated < row.quantity) { - quantity = row.quantity - row.allocated; + loadSalesOrderLineItemTable( + '#so-lines-table', + { + order: {{ order.pk }}, + status: {{ order.status }}, } - - launchModalForm(`/build/new/`, { - follow: true, - data: { - part: pk, - sales_order: {{ order.id }}, - quantity: quantity, - }, - }); - }); - - table.find(".button-buy").click(function() { - var pk = $(this).attr('pk'); - - launchModalForm("{% url 'order-parts' %}", { - data: { - parts: [pk], - }, - }); - }); - - $(".button-price").click(function() { - var pk = $(this).attr('pk'); - var idx = $(this).closest('tr').attr('data-index'); - var row = table.bootstrapTable('getData')[idx]; - - launchModalForm( - "{% url 'line-pricing' %}", - { - submit_text: '{% trans "Calculate price" %}', - data: { - line_item: pk, - quantity: row.quantity, - }, - buttons: [{name: 'update_price', - title: '{% trans "Update Unit Price" %}'},], - success: reloadTable, - } - ); - }); + ); attachNavCallbacks({ name: 'sales-order', default: 'order-items' }); -} {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/so_allocation_delete.html b/InvenTree/order/templates/order/so_allocation_delete.html deleted file mode 100644 index 34cf20083b..0000000000 --- a/InvenTree/order/templates/order/so_allocation_delete.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "modal_delete_form.html" %} -{% load i18n %} -{% load inventree_extras %} - -{% block pre_form_content %} -
- {% trans "This action will unallocate the following stock from the Sales Order" %}: -
- - {% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }} - {% if allocation.item.location %} ({{ allocation.get_location }}){% endif %} - -
-{% 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..afc689cc23 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'), @@ -37,6 +36,7 @@ purchase_order_urls = [ sales_order_detail_urls = [ url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'), url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'), + url(r'^export/', views.SalesOrderExport.as_view(), name='so-export'), url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'), ] @@ -44,12 +44,7 @@ sales_order_detail_urls = [ sales_order_urls = [ # URLs for sales order allocations url(r'^allocation/', include([ - url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'), url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'), - url(r'(?P\d+)/', include([ - url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'), - url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'), - ])), ])), # Display detail view for a single SalesOrder diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index e8b0dc03e9..08741faa2e 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -23,13 +23,12 @@ from decimal import Decimal, InvalidOperation from .models import PurchaseOrder, PurchaseOrderLineItem from .models import SalesOrder, SalesOrderLineItem from .models import SalesOrderAllocation -from .admin import POLineItemResource +from .admin import POLineItemResource, SOLineItemResource 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 from common.forms import UploadFileForm, MatchFieldForm from common.views import FileManagementFormView from common.files import FileManager @@ -37,12 +36,12 @@ from common.files import FileManager from . import forms as order_forms from part.views import PartPricing -from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView +from InvenTree.views import AjaxView, AjaxUpdateView 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") @@ -437,6 +436,33 @@ class PurchaseOrderUpload(FileManagementFormView): return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']})) +class SalesOrderExport(AjaxView): + """ + Export a sales order + + - File format can optionally be passed as a query parameter e.g. ?format=CSV + - Default file format is CSV + """ + + model = SalesOrder + + role_required = 'sales_order.view' + + def get(self, request, *args, **kwargs): + + order = get_object_or_404(SalesOrder, pk=self.kwargs.get('pk', None)) + + export_format = request.GET.get('format', 'csv') + + filename = f"{str(order)} - {order.customer.name}.{export_format}" + + dataset = SOLineItemResource().export(queryset=order.lines.all()) + + filedata = dataset.export(format=export_format) + + return DownloadFile(filedata, filename) + + class PurchaseOrderExport(AjaxView): """ File download for a purchase order @@ -451,7 +477,7 @@ class PurchaseOrderExport(AjaxView): def get(self, request, *args, **kwargs): - order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) + order = get_object_or_404(PurchaseOrder, pk=self.kwargs.get('pk', None)) export_format = request.GET.get('format', 'csv') @@ -468,202 +494,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. @@ -1172,105 +1002,6 @@ class SalesOrderAssignSerials(AjaxView, FormMixin): ) -class SalesOrderAllocationCreate(AjaxCreateView): - """ View for creating a new SalesOrderAllocation """ - - model = SalesOrderAllocation - form_class = order_forms.CreateSalesOrderAllocationForm - ajax_form_title = _('Allocate Stock to Order') - - def get_initial(self): - initials = super().get_initial().copy() - - line_id = self.request.GET.get('line', None) - - if line_id is not None: - line = SalesOrderLineItem.objects.get(pk=line_id) - - initials['line'] = line - - # Search for matching stock items, pre-fill if there is only one - items = StockItem.objects.filter(part=line.part) - - quantity = line.quantity - line.allocated_quantity() - - if quantity < 0: - quantity = 0 - - if items.count() == 1: - item = items.first() - initials['item'] = item - - # Reduce the quantity IF there is not enough stock - qmax = item.quantity - item.allocation_count() - - if qmax < quantity: - quantity = qmax - - initials['quantity'] = quantity - - return initials - - def get_form(self): - - form = super().get_form() - - line_id = form['line'].value() - - # If a line item has been specified, reduce the queryset for the stockitem accordingly - try: - line = SalesOrderLineItem.objects.get(pk=line_id) - - # Construct a queryset for allowable stock items - queryset = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) - - # Ensure the part reference matches - queryset = queryset.filter(part=line.part) - - # Exclude StockItem which are already allocated to this order - allocated = [allocation.item.pk for allocation in line.allocations.all()] - - queryset = queryset.exclude(pk__in=allocated) - - # Exclude stock items which have expired - if not InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_SALE'): - queryset = queryset.exclude(StockItem.EXPIRED_FILTER) - - form.fields['item'].queryset = queryset - - # Hide the 'line' field - form.fields['line'].widget = HiddenInput() - - except (ValueError, SalesOrderLineItem.DoesNotExist): - pass - - return form - - -class SalesOrderAllocationEdit(AjaxUpdateView): - - model = SalesOrderAllocation - form_class = order_forms.EditSalesOrderAllocationForm - ajax_form_title = _('Edit Allocation Quantity') - - def get_form(self): - form = super().get_form() - - # Prevent the user from editing particular fields - form.fields.pop('item') - form.fields.pop('line') - - return form - - -class SalesOrderAllocationDelete(AjaxDeleteView): - - model = SalesOrderAllocation - ajax_form_title = _("Remove allocation") - context_object_name = 'allocation' - ajax_template_name = "order/so_allocation_delete.html" - - class LineItemPricing(PartPricing): """ View for inspecting part pricing information """ diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 34441286ff..de37d4ea52 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1100,6 +1100,12 @@ class BomList(generics.ListCreateAPIView): except AttributeError: pass + try: + # Include or exclude pricing information in the serialized data + kwargs['include_pricing'] = str2bool(self.request.GET.get('include_pricing', True)) + except AttributeError: + pass + # Ensure the request context is passed through! kwargs['context'] = self.get_serializer_context() @@ -1141,6 +1147,18 @@ class BomList(generics.ListCreateAPIView): except (ValueError, Part.DoesNotExist): pass + include_pricing = str2bool(params.get('include_pricing', True)) + + if include_pricing: + queryset = self.annotate_pricing(queryset) + + return queryset + + def annotate_pricing(self, queryset): + """ + Add part pricing information to the queryset + """ + # Annotate with purchase prices queryset = queryset.annotate( purchase_price_min=Min('sub_part__stock_items__purchase_price'), 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/bom.py b/InvenTree/part/bom.py index 81a0a4eb00..f67e4ffe8f 100644 --- a/InvenTree/part/bom.py +++ b/InvenTree/part/bom.py @@ -189,12 +189,15 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa # Process manufacturer part for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts): - if manufacturer_part: + if manufacturer_part and manufacturer_part.manufacturer: manufacturer_name = manufacturer_part.manufacturer.name else: manufacturer_name = '' - manufacturer_mpn = manufacturer_part.MPN + if manufacturer_part: + manufacturer_mpn = manufacturer_part.MPN + else: + manufacturer_mpn = '' # Generate column names for this manufacturer k_man = manufacturer_headers[0] + "_" + str(manufacturer_idx) @@ -210,12 +213,15 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa # Process supplier parts for supplier_idx, supplier_part in enumerate(manufacturer_part.supplier_parts.all()): - if supplier_part.supplier: + if supplier_part.supplier and supplier_part.supplier: supplier_name = supplier_part.supplier.name else: supplier_name = '' - supplier_sku = supplier_part.SKU + if supplier_part: + supplier_sku = supplier_part.SKU + else: + supplier_sku = '' # Generate column names for this supplier k_sup = str(supplier_headers[0]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx) 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..509de43b68 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', @@ -418,6 +419,7 @@ class BomItemSerializer(InvenTreeModelSerializer): part_detail = kwargs.pop('part_detail', False) sub_part_detail = kwargs.pop('sub_part_detail', False) + include_pricing = kwargs.pop('include_pricing', False) super(BomItemSerializer, self).__init__(*args, **kwargs) @@ -427,6 +429,14 @@ class BomItemSerializer(InvenTreeModelSerializer): if sub_part_detail is not True: self.fields.pop('sub_part_detail') + if not include_pricing: + # Remove all pricing related fields + self.fields.pop('price_range') + self.fields.pop('purchase_price_min') + self.fields.pop('purchase_price_max') + self.fields.pop('purchase_price_avg') + self.fields.pop('purchase_price_range') + @staticmethod def setup_eager_loading(queryset): queryset = queryset.prefetch_related('part') diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 847baf8ab5..c1542ac13b 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -328,6 +328,12 @@ // If image / thumbnail data present, live update if (data.image) { $('#part-image').attr('src', data.image); + + // Reset the "modal image" view + $('#part-thumb').click(function() { + showModalImage(data.image); + }); + } else { // Otherwise, reload the page location.reload(); @@ -372,7 +378,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..d848f0e6b9 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,33 +27,26 @@ 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 from company.serializers import CompanySerializer, SupplierPartSerializer from order.models import PurchaseOrder +from order.models import SalesOrder, SalesOrderAllocation 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 +78,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 +119,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 +133,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 +385,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 +507,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 +533,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 +541,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: @@ -775,6 +646,31 @@ class StockList(generics.ListCreateAPIView): # Filter StockItem without build allocations or sales order allocations queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) + # Exclude StockItems which are already allocated to a particular SalesOrder + exclude_so_allocation = params.get('exclude_so_allocation', None) + + if exclude_so_allocation is not None: + + try: + order = SalesOrder.objects.get(pk=exclude_so_allocation) + + # Grab all the active SalesOrderAllocations for this order + allocations = SalesOrderAllocation.objects.filter( + line__pk__in=[ + line.pk for line in order.lines.all() + ] + ) + + # Exclude any stock item which is already allocated to the sales order + queryset = queryset.exclude( + pk__in=[ + a.item.pk for a in allocations + ] + ) + + except (ValueError, SalesOrder.DoesNotExist): + pass + # Does the client wish to filter by the Part ID? part_id = params.get('part', None) @@ -818,7 +714,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 +739,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 +847,7 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin): """ queryset = StockItemAttachment.objects.all() - serializer_class = StockItemAttachmentSerializer + serializer_class = StockSerializers.StockItemAttachmentSerializer filter_backends = [ DjangoFilterBackend, @@ -958,7 +866,7 @@ class StockAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix """ queryset = StockItemAttachment.objects.all() - serializer_class = StockItemAttachmentSerializer + serializer_class = StockSerializers.StockItemAttachmentSerializer class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView): @@ -967,7 +875,7 @@ class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView): """ queryset = StockItemTestResult.objects.all() - serializer_class = StockItemTestResultSerializer + serializer_class = StockSerializers.StockItemTestResultSerializer class StockItemTestResultList(generics.ListCreateAPIView): @@ -976,7 +884,7 @@ class StockItemTestResultList(generics.ListCreateAPIView): """ queryset = StockItemTestResult.objects.all() - serializer_class = StockItemTestResultSerializer + serializer_class = StockSerializers.StockItemTestResultSerializer filter_backends = [ DjangoFilterBackend, @@ -1024,7 +932,7 @@ class StockTrackingDetail(generics.RetrieveAPIView): """ queryset = StockItemTracking.objects.all() - serializer_class = StockTrackingSerializer + serializer_class = StockSerializers.StockTrackingSerializer class StockTrackingList(generics.ListAPIView): @@ -1037,7 +945,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 +981,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 +990,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 +1072,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/InvenTree/settings/navbar.html b/InvenTree/templates/InvenTree/settings/navbar.html index 9d9db36fe0..47ab116e30 100644 --- a/InvenTree/templates/InvenTree/settings/navbar.html +++ b/InvenTree/templates/InvenTree/settings/navbar.html @@ -43,6 +43,12 @@ +
  • + + {% trans "Forms" %} + +
  • +