Merge pull request #2094 from SchrodingersGat/auto-allocation-improvements

Refactor of build order stock assignment
This commit is contained in:
Oliver 2021-10-05 13:19:45 +11:00 committed by GitHub
commit e2c3690cb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1364 additions and 971 deletions

View File

@ -141,3 +141,15 @@ class InvenTreeAPITestCase(APITestCase):
self.assertEqual(response.status_code, expected_code) self.assertEqual(response.status_code, expected_code)
return response 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

View File

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

View File

@ -10,11 +10,15 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev" INVENTREE_SW_VERSION = "0.6.0 dev"
INVENTREE_API_VERSION = 12 INVENTREE_API_VERSION = 13
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
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 v12 -> 2021-09-07
- Adds API endpoint to receive stock items against a PurchaseOrder - Adds API endpoint to receive stock items against a PurchaseOrder

View File

@ -5,10 +5,12 @@ JSON API for the Build app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include from django.conf.urls import url, include
from rest_framework import filters from rest_framework import filters, generics
from rest_framework import generics from rest_framework.serializers import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters 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 .models import Build, BuildItem, BuildOrderAttachment
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
from .serializers import BuildAllocationSerializer
class BuildFilter(rest_filters.FilterSet): 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 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) queryset = BuildSerializer.annotate_queryset(queryset)
@ -181,6 +184,58 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
serializer_class = BuildSerializer 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): class BuildItemList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of BuildItem objects """ API endpoint for accessing a list of BuildItem objects
@ -210,9 +265,9 @@ class BuildItemList(generics.ListCreateAPIView):
query = BuildItem.objects.all() query = BuildItem.objects.all()
query = query.select_related('stock_item') query = query.select_related('stock_item__location')
query = query.prefetch_related('stock_item__part') query = query.select_related('stock_item__part')
query = query.prefetch_related('stock_item__part__category') query = query.select_related('stock_item__part__category')
return query return query
@ -282,16 +337,20 @@ build_api_urls = [
# Attachments # Attachments
url(r'^attachment/', include([ url(r'^attachment/', include([
url(r'^(?P<pk>\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'), url(r'^(?P<pk>\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 # Build Items
url(r'^item/', include([ url(r'^item/', include([
url('^.*$', BuildItemList.as_view(), name='api-build-item-list') url(r'^(?P<pk>\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'),
url(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'),
])), ])),
# Build Detail # Build Detail
url(r'^(?P<pk>\d+)/', BuildDetail.as_view(), name='api-build-detail'), url(r'^(?P<pk>\d+)/', include([
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
])),
# Build List # Build List
url(r'^.*$', BuildList.as_view(), name='api-build-list'), url(r'^.*$', BuildList.as_view(), name='api-build-list'),

View File

@ -3,7 +3,7 @@
- model: build.build - model: build.build
pk: 1 pk: 1
fields: fields:
part: 25 part: 100 # Build against part 100 "Bob"
batch: 'B1' batch: 'B1'
reference: "0001" reference: "0001"
title: 'Building 7 parts' title: 'Building 7 parts'

View File

@ -15,7 +15,7 @@ from InvenTree.fields import DatePickerFormField
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus
from .models import Build, BuildItem from .models import Build
from stock.models import StockLocation, StockItem 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): class CompleteBuildForm(HelperForm):
""" """
Form for marking a build as complete Form for marking a build as complete
@ -256,22 +244,3 @@ class CancelBuildForm(HelperForm):
fields = [ fields = [
'confirm_cancel' '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',
]

View File

@ -4,12 +4,14 @@ Build database model definitions
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import decimal
import os import os
from datetime import datetime from datetime import datetime
from django.contrib.auth.models import User
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
@ -584,86 +586,6 @@ class Build(MPTTModel):
self.status = BuildStatus.CANCELLED self.status = BuildStatus.CANCELLED
self.save() 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 @transaction.atomic
def unallocateOutput(self, output, part=None): def unallocateOutput(self, output, part=None):
""" """
@ -803,37 +725,6 @@ class Build(MPTTModel):
# Remove the build output from the database # Remove the build output from the database
output.delete() 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 @transaction.atomic
def subtractUntrackedStock(self, user): def subtractUntrackedStock(self, user):
""" """
@ -1165,8 +1056,10 @@ class BuildItem(models.Model):
Attributes: Attributes:
build: Link to a Build object 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 stock_item: Link to a StockItem object
quantity: Number of units allocated quantity: Number of units allocated
install_into: Destination stock item (or None)
""" """
@staticmethod @staticmethod
@ -1185,35 +1078,13 @@ class BuildItem(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.validate_unique()
self.clean() self.clean()
super().save() 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): def clean(self):
""" Check validity of the BuildItem model. """
Check validity of this BuildItem instance.
The following checks are performed: The following checks are performed:
- StockItem.part must be in the BOM of the Part object referenced by Build - StockItem.part must be in the BOM of the Part object referenced by Build
@ -1224,8 +1095,6 @@ class BuildItem(models.Model):
super().clean() super().clean()
errors = {}
try: try:
# If the 'part' is trackable, then the 'install_into' field must be set! # 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 # Allocated quantity cannot exceed available stock quantity
if self.quantity > self.stock_item.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.quantity)
q=normalize(self.stock_item.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 # 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: available = decimal.Decimal(self.stock_item.quantity)
errors['quantity'] = _('StockItem is over-allocated') 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 # Allocated quantity must be positive
if self.quantity <= 0: 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 # Quantity must be 1 for serialized stock
if self.stock_item.serialized and not self.quantity == 1: 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): except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
pass pass
if len(errors) > 0:
raise ValidationError(errors)
""" """
Attempt to find the "BomItem" which links this BuildItem to the build. 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 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: b) Either:
i) The sub_part points to the same part as the referenced StockItem 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 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: if not bom_item_valid:
raise ValidationError({ 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 @transaction.atomic

View File

@ -5,16 +5,25 @@ JSON serializers for Build API
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals 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 Case, When, Value
from django.db.models import BooleanField from django.db.models import BooleanField
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
from stock.serializers import StockItemSerializerBrief import InvenTree.helpers
from stock.serializers import LocationSerializer
from stock.models import StockItem
from stock.serializers import StockItemSerializerBrief, LocationSerializer
from part.models import BomItem
from part.serializers import PartSerializer, PartBriefSerializer from part.serializers import PartSerializer, PartBriefSerializer
from users.serializers import OwnerSerializer from users.serializers import OwnerSerializer
@ -22,7 +31,9 @@ from .models import Build, BuildItem, BuildOrderAttachment
class BuildSerializer(InvenTreeModelSerializer): class BuildSerializer(InvenTreeModelSerializer):
""" Serializes a Build object """ """
Serializes a Build object
"""
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
status_text = serializers.CharField(source='get_status_display', 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): class BuildItemSerializer(InvenTreeModelSerializer):
""" Serializes a BuildItem object """ """ Serializes a BuildItem object """

View File

@ -1,43 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
{{ block.super }}
<div class='alert alert-block alert-info'>
<strong>{% trans "Automatically Allocate Stock" %}</strong><br>
{% trans "The following stock items will be allocated to the specified build output" %}
</div>
{% if allocations %}
<table class='table table-striped table-condensed'>
<tr>
<th></th>
<th>{% trans "Part" %}</th>
<th>{% trans "Quantity" %}</th>
<th>{% trans "Location" %}</th>
</tr>
{% for item in allocations %}
<tr>
<td>
{% include "hover_image.html" with image=item.stock_item.part.image hover=True %}
</td>
<td>
{{ item.stock_item.part.full_name }}<br>
<em>{{ item.stock_item.part.description }}</em>
</td>
<td>{% decimal item.quantity %}</td>
<td>{{ item.stock_item.location }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class='alert alert-block alert-warning'>
{% trans "No stock items found that can be automatically allocated to this build" %}
<br>
{% trans "Stock items will have to be manually allocated" %}
</div>
{% endif %}
{% endblock %}

View File

@ -1,20 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-info'>
<p>
{% trans "Select a stock item to allocate to the selected build output" %}
</p>
{% if output %}
<p>
{% blocktrans %}The allocated stock will be installed into the following build output:<br><em>{{output}}</em>{% endblocktrans %}
</p>
{% endif %}
</div>
{% if no_stock %}
<div class='alert alert-danger alert-block' role='alert'>
{% blocktrans %}No stock available for {{part}}{% endblocktrans %}
</div>
{% endif %}
{% endblock %}

View File

@ -1,14 +0,0 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
<div class='alert alert-block alert-danger'>
<p>
{% trans "Are you sure you want to unallocate this stock?" %}
</p>
<p>
{% trans "The selected stock will be unallocated from the build output" %}
</p>
</div>
{% endblock %}

View File

@ -170,7 +170,7 @@
{% if build.active %} {% if build.active %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
<button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'> <button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %} <span class='fas fa-sign-in-alt'></span> {% trans "Allocate Stock" %}
</button> </button>
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'> <button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %} <span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
@ -191,7 +191,19 @@
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
<table class='table table-striped table-condensed' id='allocation-table-untracked'></table> <div id='unallocated-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
<span class='fas fa-sign-in-alt'></span>
</button>
<div class='filter-list' id='filter-list-build-items'>
<!-- Empty div for table filters-->
</div>
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='allocation-table-untracked' data-toolbar='#unallocated-toolbar'></table>
{% else %} {% else %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
{% trans "This Build Order does not have any associated untracked BOM items" %} {% trans "This Build Order does not have any associated untracked BOM items" %}
@ -306,6 +318,9 @@ var buildInfo = {
quantity: {{ build.quantity }}, quantity: {{ build.quantity }},
completed: {{ build.completed }}, completed: {{ build.completed }},
part: {{ build.part.pk }}, part: {{ build.part.pk }},
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
}; };
{% for item in build.incomplete_outputs %} {% for item in build.incomplete_outputs %}
@ -401,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 %} {% if build.has_untracked_bom_items %}
// Load allocation table for un-tracked parts // Load allocation table for un-tracked parts
loadBuildOutputAllocationTable(buildInfo, null); loadBuildOutputAllocationTable(buildInfo, null);
@ -419,12 +427,38 @@ function reloadTable() {
{% if build.active %} {% if build.active %}
$("#btn-auto-allocate").on('click', function() { $("#btn-auto-allocate").on('click', function() {
launchModalForm(
"{% url 'build-auto-allocate' build.id %}", var bom_items = $("#allocation-table-untracked").bootstrapTable("getData");
{
success: reloadTable, 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() { $('#btn-unallocate').on('click', function() {
@ -436,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() { $("#btn-order-parts").click(function() {
launchModalForm("/order/purchase-order/order-parts/", { launchModalForm("/order/purchase-order/order-parts/", {
data: { data: {

View File

@ -6,7 +6,7 @@ from datetime import datetime, timedelta
from django.urls import reverse from django.urls import reverse
from part.models import Part from part.models import Part
from build.models import Build from build.models import Build, BuildItem
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
@ -23,6 +23,7 @@ class BuildAPITest(InvenTreeAPITestCase):
'location', 'location',
'bom', 'bom',
'build', 'build',
'stock',
] ]
# Required roles to access Build API endpoints # Required roles to access Build API endpoints
@ -36,6 +37,192 @@ class BuildAPITest(InvenTreeAPITestCase):
super().setUp() 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): class BuildListTest(BuildAPITest):
""" """
Tests for the BuildOrder LIST API Tests for the BuildOrder LIST API

View File

@ -269,25 +269,6 @@ class BuildTest(TestCase):
self.assertTrue(self.build.areUntrackedPartsFullyAllocated()) 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): def test_cancel(self):
""" """
Test cancellation of the build Test cancellation of the build

View File

@ -172,7 +172,7 @@ class TestBuildAPI(APITestCase):
# Filter by 'part' status # Filter by 'part' status
response = self.client.get(url, {'part': 25}, format='json') 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 # Filter by an invalid part
response = self.client.get(url, {'part': 99999}, format='json') response = self.client.get(url, {'part': 99999}, format='json')
@ -252,34 +252,6 @@ class TestBuildViews(TestCase):
self.assertIn(build.title, content) 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): def test_build_output_complete(self):
""" """
Test the build output completion form Test the build output completion form

View File

@ -12,7 +12,6 @@ build_detail_urls = [
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'), 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'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'), 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'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'), url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
@ -20,13 +19,6 @@ build_detail_urls = [
] ]
build_urls = [ build_urls = [
url(r'item/', include([
url(r'^(?P<pk>\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<pk>\d+)/', include(build_detail_urls)), url(r'^(?P<pk>\d+)/', include(build_detail_urls)),

View File

@ -11,13 +11,13 @@ from django.views.generic import DetailView, ListView
from django.forms import HiddenInput from django.forms import HiddenInput
from part.models import Part from part.models import Part
from .models import Build, BuildItem from .models import Build
from . import forms from . import forms
from stock.models import StockLocation, StockItem 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.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 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): class BuildOutputCreate(AjaxUpdateView):
""" """
Create a new build output (StockItem) for a given build. Create a new build output (StockItem) for a given build.
@ -626,268 +565,3 @@ class BuildDelete(AjaxDeleteView):
model = Build model = Build
ajax_template_name = 'build/delete_build.html' ajax_template_name = 'build/delete_build.html'
ajax_form_title = _('Delete Build Order') 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

View File

@ -1,18 +1,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import os
import logging
from PIL import UnidentifiedImageError
from django.apps import AppConfig 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): class CompanyConfig(AppConfig):
@ -23,29 +11,4 @@ class CompanyConfig(AppConfig):
This function is called whenever the Company app is loaded. This function is called whenever the Company app is loaded.
""" """
if canAppAccessDatabase(): pass
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

View File

@ -7,14 +7,11 @@ from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include from django.conf.urls import url, include
from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
from rest_framework import generics from rest_framework import generics
from rest_framework import filters, status from rest_framework import filters, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
@ -235,6 +232,7 @@ class POReceive(generics.CreateAPIView):
# Pass the purchase order through to the serializer for validation # Pass the purchase order through to the serializer for validation
context['order'] = self.get_order() context['order'] = self.get_order()
context['request'] = self.request
return context return context
@ -252,76 +250,6 @@ class POReceive(generics.CreateAPIView):
return order return order
def create(self, request, *args, **kwargs):
# Which purchase order are we receiving against?
self.order = self.get_order()
# Validate the serialized data
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# 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))
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):
"""
Receive the items
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
"""
data = serializer.validated_data
location = data['location']
items = data['items']
# 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', ''),
)
class POLineItemList(generics.ListCreateAPIView): class POLineItemList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of POLineItem objects """ API endpoint for accessing a list of POLineItem objects

View File

@ -7,7 +7,8 @@ from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _ 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 Case, When, Value
from django.db.models import BooleanField, ExpressionWrapper, F from django.db.models import BooleanField, ExpressionWrapper, F
@ -277,35 +278,75 @@ class POReceiveSerializer(serializers.Serializer):
help_text=_('Select destination location for received items'), help_text=_('Select destination location for received items'),
) )
def is_valid(self, raise_exception=False): def validate(self, data):
super().is_valid(raise_exception) super().validate(data)
# Custom validation
data = self.validated_data
items = data.get('items', []) items = data.get('items', [])
if len(items) == 0: if len(items) == 0:
self._errors['items'] = _('Line items must be provided') raise ValidationError({
else: 'items': _('Line items must be provided')
# Ensure barcodes are unique })
unique_barcodes = set()
# 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):
data = self.validated_data
request = self.context['request']
order = self.context['order']
items = data['items']
location = data.get('location', None)
# 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 into stock
with transaction.atomic():
for item in items: for item in items:
barcode = item.get('barcode', '')
if barcode: try:
if barcode in unique_barcodes: order.receive_line_item(
self._errors['items'] = _('Supplied barcode values must be unique') item['line_item'],
break item['location'],
else: item['quantity'],
unique_barcodes.add(barcode) request.user,
status=item['status'],
if self._errors and raise_exception: barcode=item.get('barcode', ''),
raise ValidationError(self.errors) )
except (ValidationError, DjangoValidationError) as exc:
return not bool(self._errors) # Catch model errors and re-throw as DRF errors
raise ValidationError(detail=serializers.as_serializer_error(exc))
class Meta: class Meta:
fields = [ fields = [

View File

@ -1,13 +1,9 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import os
import logging import logging
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
from PIL import UnidentifiedImageError
from InvenTree.ready import canAppAccessDatabase from InvenTree.ready import canAppAccessDatabase
@ -24,40 +20,8 @@ class PartConfig(AppConfig):
""" """
if canAppAccessDatabase(): if canAppAccessDatabase():
self.generate_part_thumbnails()
self.update_trackable_status() 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): def update_trackable_status(self):
""" """
Check for any instances where a trackable part is used in the BOM 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) items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True)
for item in items: 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.trackable = True
item.part.clean() item.part.clean()
item.part.save() item.part.save()

View File

@ -30,4 +30,11 @@
fields: fields:
part: 100 part: 100
sub_part: 50 sub_part: 50
quantity: 3 quantity: 3
- model: part.bomitem
pk: 5
fields:
part: 1
sub_part: 5
quantity: 3

View File

@ -4,6 +4,7 @@ Part database model definitions
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import decimal
import os import os
import logging import logging
@ -1530,10 +1531,13 @@ class Part(MPTTModel):
for item in self.get_bom_items().all().select_related('sub_part'): for item in self.get_bom_items().all().select_related('sub_part'):
if item.sub_part.pk == self.pk: 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 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: if prices is None:
continue continue
@ -2329,6 +2333,23 @@ class BomItem(models.Model):
def get_api_url(): def get_api_url():
return reverse('api-bom-list') 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): def save(self, *args, **kwargs):
self.clean() self.clean()

View File

@ -277,7 +277,7 @@ class PartAPITest(InvenTreeAPITestCase):
""" There should be 4 BomItem objects in the database """ """ There should be 4 BomItem objects in the database """
url = reverse('api-bom-list') url = reverse('api-bom-list')
response = self.client.get(url, format='json') 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): def test_get_bom_detail(self):
# Get the detail for a single BomItem # Get the detail for a single BomItem

View File

@ -120,7 +120,13 @@ class BomItemTest(TestCase):
def test_pricing(self): def test_pricing(self):
self.bob.get_price(1) 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 # remove internal price for R_2K2_0805
self.r1.internal_price_breaks.delete() 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))
)

View File

@ -2,11 +2,18 @@
JSON API for the Stock app JSON API for the Stock app
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include from django.conf.urls import url, include
from django.urls import reverse from django.urls import reverse
from django.http import JsonResponse from django.http import JsonResponse
from django.db.models import Q from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from rest_framework import status from rest_framework import status
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
@ -22,7 +29,7 @@ from .models import StockItemTracking
from .models import StockItemAttachment from .models import StockItemAttachment
from .models import StockItemTestResult from .models import StockItemTestResult
from part.models import Part, PartCategory from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
@ -45,10 +52,6 @@ from InvenTree.helpers import str2bool, isNull
from InvenTree.api import AttachmentMixin from InvenTree.api import AttachmentMixin
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta
class StockCategoryTree(TreeSerializer): class StockCategoryTree(TreeSerializer):
title = _('Stock') title = _('Stock')
@ -670,14 +673,14 @@ class StockList(generics.ListCreateAPIView):
return queryset return queryset
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
"""
Custom filtering for the StockItem queryset
"""
params = self.request.query_params params = self.request.query_params
queryset = super().filter_queryset(queryset) 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) supplier_part = params.get('supplier_part', None)
if supplier_part: if supplier_part:
@ -818,7 +821,7 @@ class StockList(generics.ListCreateAPIView):
if loc_id is not None: if loc_id is not None:
# Filter by 'null' location (i.e. top-level items) # 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) queryset = queryset.filter(location=None)
else: else:
try: try:
@ -843,6 +846,18 @@ class StockList(generics.ListCreateAPIView):
except (ValueError, PartCategory.DoesNotExist): except (ValueError, PartCategory.DoesNotExist):
raise ValidationError({"category": "Invalid category id specified"}) 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 # Filter by StockItem status
status = params.get('status', None) status = params.get('status', None)

View File

@ -4,7 +4,6 @@
/* globals /* globals
buildStatusDisplay, buildStatusDisplay,
constructForm, constructForm,
getFieldByName,
global_settings, global_settings,
imageHoverIcon, imageHoverIcon,
inventreeGet, inventreeGet,
@ -20,6 +19,7 @@
*/ */
/* exported /* exported
allocateStockToBuild,
editBuildOrder, editBuildOrder,
loadAllocationTable, loadAllocationTable,
loadBuildOrderAllocationTable, loadBuildOrderAllocationTable,
@ -102,6 +102,7 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
*/ */
var buildId = buildInfo.pk; var buildId = buildInfo.pk;
var partId = buildInfo.part;
var outputId = 'untracked'; var outputId = 'untracked';
@ -120,11 +121,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
var html = `<div class='btn-group float-right' role='group'>`; var html = `<div class='btn-group float-right' role='group'>`;
// "Auto" allocation only works for untracked stock items if (lines > 0) {
if (!output && lines > 0) {
html += makeIconButton( html += makeIconButton(
'fa-magic icon-blue', 'button-output-auto', outputId, 'fa-sign-in-alt icon-blue', 'button-output-auto', outputId,
'{% trans "Auto-allocate stock items to this output" %}', '{% trans "Allocate stock items to this build output" %}',
); );
} }
@ -136,7 +136,6 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
); );
} }
if (output) { if (output) {
// Add a button to "complete" the particular build output // Add a button to "complete" the particular build output
@ -163,11 +162,17 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
// Add callbacks for the buttons // Add callbacks for the buttons
$(panel).find(`#button-output-auto-${outputId}`).click(function() { $(panel).find(`#button-output-auto-${outputId}`).click(function() {
var bom_items = $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('getData');
// Launch modal dialog to perform auto-allocation // Launch modal dialog to perform auto-allocation
launchModalForm(`/build/${buildId}/auto-allocate/`, allocateStockToBuild(
buildId,
partId,
bom_items,
{ {
data: { source_location: buildInfo.source_location,
}, output: outputId,
success: reloadTable, success: reloadTable,
} }
); );
@ -344,18 +349,26 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
function requiredQuantity(row) { function requiredQuantity(row) {
// Return the requied quantity for a given row // Return the requied quantity for a given row
var quantity = 0;
if (output) { if (output) {
// "Tracked" parts are calculated against individual build outputs // "Tracked" parts are calculated against individual build outputs
return row.quantity * output.quantity; quantity = row.quantity * output.quantity;
} else { } else {
// "Untracked" parts are specified against the build itself // "Untracked" parts are specified against the build itself
return row.quantity * buildInfo.quantity; quantity = row.quantity * buildInfo.quantity;
} }
// Store the required quantity in the row data
row.required = quantity;
return quantity;
} }
function sumAllocations(row) { function sumAllocations(row) {
// Calculat total allocations for a given row // Calculat total allocations for a given row
if (!row.allocations) { if (!row.allocations) {
row.allocated = 0;
return 0; return 0;
} }
@ -365,6 +378,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
quantity += item.quantity; quantity += item.quantity;
}); });
row.allocated = quantity;
return quantity; return quantity;
} }
@ -377,52 +392,28 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Primary key of the 'sub_part' // Primary key of the 'sub_part'
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
// Launch form to allocate new stock against this output // Extract BomItem information from this row
launchModalForm('{% url "build-item-create" %}', { var row = $(table).bootstrapTable('getRowByUniqueId', pk);
success: reloadTable,
data: { if (!row) {
part: pk, console.log('WARNING: getRowByUniqueId returned null');
build: buildId, return;
install_into: outputId, }
},
secondary: [ allocateStockToBuild(
{ buildId,
field: 'stock_item', partId,
label: '{% trans "New Stock Item" %}', [
title: '{% trans "Create new Stock Item" %}', row,
url: '{% url "stock-item-create" %}',
data: {
part: pk,
},
},
], ],
callback: [ {
{ source_location: buildInfo.source_location,
field: 'stock_item', success: function(data) {
action: function(value) { $(table).bootstrapTable('refresh');
inventreeGet( },
`/api/stock/${value}/`, {}, output: output == null ? null : output.pk,
{ }
success: function(response) { );
// How many items are actually available for the given stock item?
var available = response.quantity - response.allocated;
var field = getFieldByName('#modal-form', 'quantity');
// Allocation quantity initial value
var initial = field.attr('value');
if (available < initial) {
field.val(available);
}
}
}
);
}
}
]
});
}); });
// Callback for 'buy' button // Callback for 'buy' button
@ -636,11 +627,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
text = `{% trans "Quantity" %}: ${row.quantity}`; text = `{% trans "Quantity" %}: ${row.quantity}`;
} }
{% if build.status == BuildStatus.COMPLETE %} var pk = row.stock_item || row.pk;
url = `/stock/item/${row.pk}/`;
{% else %} url = `/stock/item/${pk}/`;
url = `/stock/item/${row.stock_item}/`;
{% endif %}
return renderLink(text, url); return renderLink(text, url);
} }
@ -687,22 +676,31 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Assign button callbacks to the newly created allocation buttons // Assign button callbacks to the newly created allocation buttons
subTable.find('.button-allocation-edit').click(function() { subTable.find('.button-allocation-edit').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
launchModalForm(`/build/item/${pk}/edit/`, {
success: reloadTable, constructForm(`/api/build/item/${pk}/`, {
fields: {
quantity: {},
},
title: '{% trans "Edit Allocation" %}',
onSuccess: reloadTable,
}); });
}); });
subTable.find('.button-allocation-delete').click(function() { subTable.find('.button-allocation-delete').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
launchModalForm(`/build/item/${pk}/delete/`, {
success: reloadTable, constructForm(`/api/build/item/${pk}/`, {
method: 'DELETE',
title: '{% trans "Remove Allocation" %}',
onSuccess: reloadTable,
}); });
}); });
}, },
columns: [ columns: [
{ {
field: 'pk', visible: true,
visible: false, switchable: false,
checkbox: true,
}, },
{ {
field: 'sub_part_detail.full_name', field: 'sub_part_detail.full_name',
@ -824,6 +822,317 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
} }
/**
* Allocate stock items to a build
*
* arguments:
* - buildId: ID / PK value for the build
* - partId: ID / PK value for the part being built
* - bom_items: A list of BomItem objects to be allocated
*
* options:
* - output: ID / PK of the associated build output (or null for untracked items)
* - source_location: ID / PK of the top-level StockLocation to take parts from (or null)
*/
function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
// ID of the associated "build output" (or null)
var output_id = options.output || null;
var source_location = options.source_location;
function renderBomItemRow(bom_item, quantity) {
var pk = bom_item.pk;
var sub_part = bom_item.sub_part_detail;
var thumb = thumbnailImage(bom_item.sub_part_detail.thumbnail);
var delete_button = `<div class='btn-group float-right' role='group'>`;
delete_button += makeIconButton(
'fa-times icon-red',
'button-row-remove',
pk,
'{% trans "Remove row" %}',
);
delete_button += `</div>`;
var quantity_input = constructField(
`items_quantity_${pk}`,
{
type: 'decimal',
min_value: 0,
value: quantity || 0,
title: '{% trans "Specify stock allocation quantity" %}',
required: true,
},
{
hideLabels: true,
}
);
var allocated_display = makeProgressBar(
bom_item.allocated,
bom_item.required,
);
var stock_input = constructField(
`items_stock_item_${pk}`,
{
type: 'related field',
required: 'true',
},
{
hideLabels: true,
}
);
// var stock_input = constructRelatedFieldInput(`items_stock_item_${pk}`);
var html = `
<tr id='allocation_row_${pk}' class='part-allocation-row'>
<td id='part_${pk}'>
${thumb} ${sub_part.full_name}
</td>
<td id='allocated_${pk}'>
${allocated_display}
</td>
<td id='stock_item_${pk}'>
${stock_input}
</td>
<td id='quantity_${pk}'>
${quantity_input}
</td>
<td id='buttons_${pk}'>
${delete_button}
</td>
</tr>
`;
return html;
}
var table_entries = '';
for (var idx = 0; idx < bom_items.length; idx++) {
var bom_item = bom_items[idx];
var required = bom_item.required || 0;
var allocated = bom_item.allocated || 0;
var remaining = required - allocated;
if (remaining < 0) {
remaining = 0;
}
table_entries += renderBomItemRow(bom_item, remaining);
}
if (bom_items.length == 0) {
showAlertDialog(
'{% trans "Select Parts" %}',
'{% trans "You must select at least one part to allocate" %}',
);
return;
}
var html = ``;
// Render a "take from" input
html += constructField(
'take_from',
{
type: 'related field',
label: '{% trans "Source Location" %}',
help_text: '{% trans "Select source location (leave blank to take from all locations)" %}',
required: false,
},
{},
);
// Create table of parts
html += `
<table class='table table-striped table-condensed' id='stock-allocation-table'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Allocated" %}</th>
<th style='min-width: 250px;'>{% trans "Stock Item" %}</th>
<th>{% trans "Quantity" %}</th>
<th></th>
</tr>
</thead>
<tbody>
${table_entries}
</tbody>
</table>
`;
constructForm(`/api/build/${build_id}/allocate/`, {
method: 'POST',
fields: {},
preFormContent: html,
confirm: true,
confirmMessage: '{% trans "Confirm stock allocation" %}',
title: '{% trans "Allocate Stock Items to Build Order" %}',
afterRender: function(fields, options) {
var take_from_field = {
name: 'take_from',
model: 'stocklocation',
api_url: '{% url "api-location-list" %}',
required: false,
type: 'related field',
value: source_location,
noResults: function(query) {
return '{% trans "No matching stock locations" %}';
},
};
// Initialize "take from" field
initializeRelatedField(
take_from_field,
null,
options,
);
// Initialize stock item fields
bom_items.forEach(function(bom_item) {
initializeRelatedField(
{
name: `items_stock_item_${bom_item.pk}`,
api_url: '{% url "api-stock-list" %}',
filters: {
bom_item: bom_item.pk,
in_stock: true,
part_detail: false,
location_detail: true,
},
model: 'stockitem',
required: true,
render_part_detail: false,
render_location_detail: true,
auto_fill: true,
adjustFilters: function(filters) {
// Restrict query to the selected location
var location = getFormFieldValue(
'take_from',
{},
{
modal: options.modal,
}
);
filters.location = location;
filters.cascade = true;
return filters;
},
noResults: function(query) {
return '{% trans "No matching stock items" %}';
}
},
null,
options,
);
});
// Add callback to "clear" button for take_from field
addClearCallback(
'take_from',
take_from_field,
options,
);
// Add button callbacks
$(options.modal).find('.button-row-remove').click(function() {
var pk = $(this).attr('pk');
$(options.modal).find(`#allocation_row_${pk}`).remove();
});
},
onSubmit: function(fields, opts) {
// Extract elements from the form
var data = {
items: []
};
var item_pk_values = [];
bom_items.forEach(function(item) {
var quantity = getFormFieldValue(
`items_quantity_${item.pk}`,
{},
{
modal: opts.modal,
},
);
var stock_item = getFormFieldValue(
`items_stock_item_${item.pk}`,
{},
{
modal: opts.modal,
}
);
if (quantity != null) {
data.items.push({
bom_item: item.pk,
stock_item: stock_item,
quantity: quantity,
output: output_id,
});
item_pk_values.push(item.pk);
}
});
// Provide nested values
opts.nested = {
'items': item_pk_values
};
inventreePut(
opts.url,
data,
{
method: 'POST',
success: function(response) {
// Hide the modal
$(opts.modal).modal('hide');
if (options.success) {
options.success(response);
}
},
error: function(xhr) {
switch (xhr.status) {
case 400:
handleFormErrors(xhr.responseJSON, fields, opts);
break;
default:
$(opts.modal).modal('hide');
showApiError(xhr);
break;
}
}
}
);
},
});
}
function loadBuildTable(table, options) { function loadBuildTable(table, options) {
// Display a table of Build objects // Display a table of Build objects

View File

@ -728,10 +728,17 @@ function updateFieldValues(fields, options) {
} }
} }
/*
* Update the value of a named field
*/
function updateFieldValue(name, value, field, options) { function updateFieldValue(name, value, field, options) {
var el = $(options.modal).find(`#id_${name}`); var el = $(options.modal).find(`#id_${name}`);
if (!el) {
console.log(`WARNING: updateFieldValue could not find field '${name}'`);
return;
}
switch (field.type) { switch (field.type) {
case 'boolean': case 'boolean':
el.prop('checked', value); el.prop('checked', value);
@ -864,6 +871,78 @@ function clearFormErrors(options) {
$(options.modal).find('#non-field-errors').html(''); $(options.modal).find('#non-field-errors').html('');
} }
/*
* Display form error messages as returned from the server,
* specifically for errors returned in an array.
*
* We need to know the unique ID of each item in the array,
* and the array length must equal the length of the array returned from the server
*
* arguments:
* - response: The JSON error response from the server
* - parent: The name of the parent field e.g. "items"
* - options: The global options struct
*
* options:
* - nested: A map of nested ID values for the "parent" field
* e.g.
* {
* "items": [
* 1,
* 2,
* 12
* ]
* }
*
*/
function handleNestedErrors(errors, field_name, options) {
var error_list = errors[field_name];
// Ignore null or empty list
if (!error_list) {
return;
}
var nest_list = nest_list = options['nested'][field_name];
// Nest list must be provided!
if (!nest_list) {
console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`);
return;
}
for (var idx = 0; idx < error_list.length; idx++) {
var error_item = error_list[idx];
if (idx >= nest_list.length) {
console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`);
break;
}
// Extract the particular ID of the nested item
var nest_id = nest_list[idx];
// Here, error_item is a map of field names to error messages
for (sub_field_name in error_item) {
var errors = error_item[sub_field_name];
// Find the target (nested) field
var target = `${field_name}_${sub_field_name}_${nest_id}`;
for (var ii = errors.length-1; ii >= 0; ii--) {
var error_text = errors[ii];
addFieldErrorMessage(target, error_text, ii, options);
}
}
}
}
/* /*
* Display form error messages as returned from the server. * Display form error messages as returned from the server.
@ -913,28 +992,30 @@ function handleFormErrors(errors, fields, options) {
for (var field_name in errors) { for (var field_name in errors) {
// Add the 'has-error' class if (field_name in fields) {
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`); var field = fields[field_name];
var field_errors = errors[field_name]; if ((field.type == 'field') && ('child' in field)) {
// This is a "nested" field
handleNestedErrors(errors, field_name, options);
} else {
// This is a "simple" field
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) { var field_errors = errors[field_name];
first_error_field = field_name;
}
// Add an entry for each returned error message if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
for (var ii = field_errors.length-1; ii >= 0; ii--) { first_error_field = field_name;
}
var error_text = field_errors[ii]; // Add an entry for each returned error message
for (var ii = field_errors.length-1; ii >= 0; ii--) {
var error_html = ` var error_text = field_errors[ii];
<span id='error_${ii+1}_id_${field_name}' class='help-block form-error-message'>
<strong>${error_text}</strong>
</span>`;
field_dom.append(error_html); addFieldErrorMessage(field_name, error_text, ii, options);
}
}
} }
} }
@ -952,6 +1033,30 @@ function handleFormErrors(errors, fields, options) {
} }
/*
* Add a rendered error message to the provided field
*/
function addFieldErrorMessage(field_name, error_text, error_idx, options) {
// Add the 'has-error' class
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
var field_dom = $(options.modal).find(`#errors-${field_name}`);
if (field_dom) {
var error_html = `
<span id='error_${error_idx}_id_${field_name}' class='help-block form-error-message'>
<strong>${error_text}</strong>
</span>`;
field_dom.append(error_html);
} else {
console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}`);
}
}
function isFieldVisible(field, options) { function isFieldVisible(field, options) {
return $(options.modal).find(`#div_id_${field}`).is(':visible'); return $(options.modal).find(`#div_id_${field}`).is(':visible');
@ -1007,7 +1112,14 @@ function addClearCallbacks(fields, options) {
function addClearCallback(name, field, options) { function addClearCallback(name, field, options) {
$(options.modal).find(`#clear_${name}`).click(function() { var el = $(options.modal).find(`#clear_${name}`);
if (!el) {
console.log(`WARNING: addClearCallback could not find field '${name}'`);
return;
}
el.click(function() {
updateFieldValue(name, null, field, options); updateFieldValue(name, null, field, options);
}); });
} }
@ -1168,7 +1280,7 @@ function addSecondaryModal(field, fields, options) {
/* /*
* Initializea single related-field * Initialize a single related-field
* *
* argument: * argument:
* - modal: DOM identifier for the modal window * - modal: DOM identifier for the modal window
@ -1182,7 +1294,7 @@ function initializeRelatedField(field, fields, options) {
if (!field.api_url) { if (!field.api_url) {
// TODO: Provide manual api_url option? // TODO: Provide manual api_url option?
console.log(`Related field '${name}' missing 'api_url' parameter.`); console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
return; return;
} }
@ -1203,6 +1315,15 @@ function initializeRelatedField(field, fields, options) {
placeholder: '', placeholder: '',
dropdownParent: $(options.modal), dropdownParent: $(options.modal),
dropdownAutoWidth: false, dropdownAutoWidth: false,
language: {
noResults: function(query) {
if (field.noResults) {
return field.noResults(query);
} else {
return '{% trans "No results found" %}';
}
}
},
ajax: { ajax: {
url: field.api_url, url: field.api_url,
dataType: 'json', dataType: 'json',
@ -1225,6 +1346,11 @@ function initializeRelatedField(field, fields, options) {
query.search = params.term; query.search = params.term;
query.offset = offset; query.offset = offset;
query.limit = pageSize; query.limit = pageSize;
// Allow custom run-time filter augmentation
if ('adjustFilters' in field) {
query = field.adjustFilters(query);
}
return query; return query;
}, },
@ -1319,6 +1445,7 @@ function initializeRelatedField(field, fields, options) {
// If a 'value' is already defined, grab the model info from the server // If a 'value' is already defined, grab the model info from the server
if (field.value) { if (field.value) {
var pk = field.value; var pk = field.value;
var url = `${field.api_url}/${pk}/`.replace('//', '/'); var url = `${field.api_url}/${pk}/`.replace('//', '/');
@ -1327,6 +1454,24 @@ function initializeRelatedField(field, fields, options) {
setRelatedFieldData(name, data, options); setRelatedFieldData(name, data, options);
} }
}); });
} else if (field.auto_fill) {
// Attempt to auto-fill the field
var filters = field.filters || {};
// Enforce pagination, limit to a single return (for fast query)
filters.limit = 1;
filters.offset = 0;
inventreeGet(field.api_url, field.filters || {}, {
success: function(data) {
// Only a single result is available, given the provided filters
if (data.count == 1) {
setRelatedFieldData(name, data.results[0], options);
}
}
});
} }
} }
@ -1884,7 +2029,7 @@ function constructChoiceInput(name, parameters) {
*/ */
function constructRelatedFieldInput(name) { function constructRelatedFieldInput(name) {
var html = `<select id='id_${name}' class='select form-control' name='${name}'></select>`; var html = `<select id='id_${name}' class='select form-control' name='${name}' style='width: 100%;'></select>`;
// Don't load any options - they will be filled via an AJAX request // Don't load any options - they will be filled via an AJAX request

View File

@ -65,7 +65,7 @@ function imageHoverIcon(url) {
function thumbnailImage(url) { function thumbnailImage(url) {
if (!url) { if (!url) {
url = '/static/img/blank_img.png'; url = blankImage();
} }
// TODO: Support insertion of custom classes // TODO: Support insertion of custom classes

View File

@ -37,7 +37,7 @@ function renderCompany(name, data, parameters, options) {
html += `<span><b>${data.name}</b></span> - <i>${data.description}</i>`; html += `<span><b>${data.name}</b></span> - <i>${data.description}</i>`;
html += `<span class='float-right'>{% trans "Company ID" %}: ${data.pk}</span>`; html += `<span class='float-right'><small>{% trans "Company ID" %}: ${data.pk}</small></span>`;
return html; return html;
} }
@ -47,22 +47,59 @@ function renderCompany(name, data, parameters, options) {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
function renderStockItem(name, data, parameters, options) { function renderStockItem(name, data, parameters, options) {
var image = data.part_detail.thumbnail || data.part_detail.image || blankImage(); var image = blankImage();
var html = `<img src='${image}' class='select2-thumbnail'>`; if (data.part_detail) {
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
if (data.serial && data.quantity == 1) {
html += ` - <i>{% trans "Serial Number" %}: ${data.serial}`;
} else {
html += ` - <i>{% trans "Quantity" %}: ${data.quantity}`;
} }
if (data.part_detail.description) { var html = '';
var render_part_detail = true;
if ('render_part_detail' in parameters) {
render_part_detail = parameters['render_part_detail'];
}
if (render_part_detail) {
html += `<img src='${image}' class='select2-thumbnail'>`;
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
}
html += '<span>';
if (data.serial && data.quantity == 1) {
html += `{% trans "Serial Number" %}: ${data.serial}`;
} else {
html += `{% trans "Quantity" %}: ${data.quantity}`;
}
html += '</span>';
if (render_part_detail && data.part_detail.description) {
html += `<p><small>${data.part_detail.description}</small></p>`; html += `<p><small>${data.part_detail.description}</small></p>`;
} }
var render_stock_id = true;
if ('render_stock_id' in parameters) {
render_stock_id = parameters['render_stock_id'];
}
if (render_stock_id) {
html += `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
}
var render_location_detail = false;
if ('render_location_detail' in parameters) {
render_location_detail = parameters['render_location_detail'];
}
if (render_location_detail && data.location_detail) {
html += `<span> - ${data.location_detail.name}</span>`;
}
return html; return html;
} }
@ -79,7 +116,7 @@ function renderStockLocation(name, data, parameters, options) {
html += ` - <i>${data.description}</i>`; html += ` - <i>${data.description}</i>`;
} }
html += `<span class='float-right'>{% trans "Location ID" %}: ${data.pk}</span>`; html += `<span class='float-right'><small>{% trans "Location ID" %}: ${data.pk}</small></span>`;
return html; return html;
} }
@ -96,7 +133,7 @@ function renderBuild(name, data, parameters, options) {
var html = select2Thumbnail(image); var html = select2Thumbnail(image);
html += `<span><b>${data.reference}</b></span> - ${data.quantity} x ${data.part_detail.full_name}`; html += `<span><b>${data.reference}</b></span> - ${data.quantity} x ${data.part_detail.full_name}`;
html += `<span class='float-right'>{% trans "Build ID" %}: ${data.pk}</span>`; html += `<span class='float-right'><small>{% trans "Build ID" %}: ${data.pk}</span></span>`;
html += `<p><i>${data.title}</i></p>`; html += `<p><i>${data.title}</i></p>`;
@ -116,7 +153,7 @@ function renderPart(name, data, parameters, options) {
html += ` - <i>${data.description}</i>`; html += ` - <i>${data.description}</i>`;
} }
html += `<span class='float-right'>{% trans "Part ID" %}: ${data.pk}</span>`; html += `<span class='float-right'><small>{% trans "Part ID" %}: ${data.pk}</small></span>`;
return html; return html;
} }
@ -168,7 +205,7 @@ function renderPartCategory(name, data, parameters, options) {
html += ` - <i>${data.description}</i>`; html += ` - <i>${data.description}</i>`;
} }
html += `<span class='float-right'>{% trans "Category ID" %}: ${data.pk}</span>`; html += `<span class='float-right'><small>{% trans "Category ID" %}: ${data.pk}</small></span>`;
return html; return html;
} }
@ -205,7 +242,7 @@ function renderManufacturerPart(name, data, parameters, options) {
html += ` <span><b>${data.manufacturer_detail.name}</b> - ${data.MPN}</span>`; html += ` <span><b>${data.manufacturer_detail.name}</b> - ${data.MPN}</span>`;
html += ` - <i>${data.part_detail.full_name}</i>`; html += ` - <i>${data.part_detail.full_name}</i>`;
html += `<span class='float-right'>{% trans "Manufacturer Part ID" %}: ${data.pk}</span>`; html += `<span class='float-right'><small>{% trans "Manufacturer Part ID" %}: ${data.pk}</small></span>`;
return html; return html;
} }
@ -234,7 +271,7 @@ function renderSupplierPart(name, data, parameters, options) {
html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`; html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`;
html += ` - <i>${data.part_detail.full_name}</i>`; html += ` - <i>${data.part_detail.full_name}</i>`;
html += `<span class='float-right'>{% trans "Supplier Part ID" %}: ${data.pk}</span>`; html += `<span class='float-right'><small>{% trans "Supplier Part ID" %}: ${data.pk}</small></span>`;
return html; return html;

View File

@ -127,13 +127,20 @@ def worker(c):
@task @task
def rebuild(c): def rebuild_models(c):
""" """
Rebuild database models with MPTT structures Rebuild database models with MPTT structures
""" """
manage(c, "rebuild_models") manage(c, "rebuild_models", pty=True)
@task
def rebuild_thumbnails(c):
"""
Rebuild missing image thumbnails
"""
manage(c, "rebuild_thumbnails", pty=True)
@task @task
def clean_settings(c): def clean_settings(c):
@ -143,7 +150,7 @@ def clean_settings(c):
manage(c, "clean_settings") manage(c, "clean_settings")
@task(post=[rebuild]) @task(post=[rebuild_models, rebuild_thumbnails])
def migrate(c): def migrate(c):
""" """
Performs database migrations. Performs database migrations.
@ -341,7 +348,7 @@ def export_records(c, filename='data.json'):
print("Data export completed") print("Data export completed")
@task(help={'filename': 'Input filename'}, post=[rebuild]) @task(help={'filename': 'Input filename'}, post=[rebuild_models, rebuild_thumbnails])
def import_records(c, filename='data.json'): def import_records(c, filename='data.json'):
""" """
Import database records from a file Import database records from a file
@ -399,7 +406,7 @@ def delete_data(c, force=False):
manage(c, 'flush') manage(c, 'flush')
@task(post=[rebuild]) @task(post=[rebuild_models, rebuild_thumbnails])
def import_fixtures(c): def import_fixtures(c):
""" """
Import fixture data into the database. Import fixture data into the database.