mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2094 from SchrodingersGat/auto-allocation-improvements
Refactor of build order stock assignment
This commit is contained in:
commit
e2c3690cb8
@ -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
|
||||
|
@ -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
|
@ -10,11 +10,15 @@ import common.models
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
@ -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<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
|
||||
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
|
||||
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
|
||||
url(r'^.*$', BuildList.as_view(), name='api-build-list'),
|
||||
|
@ -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'
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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 """
|
||||
|
||||
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -170,7 +170,7 @@
|
||||
{% if build.active %}
|
||||
<div class='btn-group' role='group'>
|
||||
<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 class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
||||
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
||||
@ -191,7 +191,19 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "This Build Order does not have any associated untracked BOM items" %}
|
||||
@ -306,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 %}
|
||||
@ -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 %}
|
||||
// Load allocation table for un-tracked parts
|
||||
loadBuildOutputAllocationTable(buildInfo, null);
|
||||
@ -419,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() {
|
||||
@ -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() {
|
||||
launchModalForm("/order/purchase-order/order-parts/", {
|
||||
data: {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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<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)),
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -7,14 +7,11 @@ 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_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 +232,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,76 +250,6 @@ class POReceive(generics.CreateAPIView):
|
||||
|
||||
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):
|
||||
""" API endpoint for accessing a list of POLineItem objects
|
||||
|
@ -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
|
||||
|
||||
@ -277,35 +278,75 @@ 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', [])
|
||||
|
||||
if len(items) == 0:
|
||||
self._errors['items'] = _('Line items must be provided')
|
||||
else:
|
||||
# Ensure barcodes are unique
|
||||
unique_barcodes = set()
|
||||
raise ValidationError({
|
||||
'items': _('Line items must be provided')
|
||||
})
|
||||
|
||||
# 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:
|
||||
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)
|
||||
|
||||
if self._errors and raise_exception:
|
||||
raise ValidationError(self.errors)
|
||||
|
||||
return not bool(self._errors)
|
||||
try:
|
||||
order.receive_line_item(
|
||||
item['line_item'],
|
||||
item['location'],
|
||||
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 = [
|
||||
|
@ -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()
|
||||
|
@ -30,4 +30,11 @@
|
||||
fields:
|
||||
part: 100
|
||||
sub_part: 50
|
||||
quantity: 3
|
||||
quantity: 3
|
||||
|
||||
- model: part.bomitem
|
||||
pk: 5
|
||||
fields:
|
||||
part: 1
|
||||
sub_part: 5
|
||||
quantity: 3
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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))
|
||||
)
|
||||
|
@ -2,11 +2,18 @@
|
||||
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.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
|
||||
@ -22,7 +29,7 @@ from .models import StockItemTracking
|
||||
from .models import StockItemAttachment
|
||||
from .models import StockItemTestResult
|
||||
|
||||
from part.models import Part, PartCategory
|
||||
from part.models import BomItem, Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from company.models import Company, SupplierPart
|
||||
@ -45,10 +52,6 @@ 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')
|
||||
@ -670,14 +673,14 @@ class StockList(generics.ListCreateAPIView):
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Custom filtering for the StockItem queryset
|
||||
"""
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# Perform basic filtering:
|
||||
# Note: We do not let DRF filter here, it be slow AF
|
||||
|
||||
supplier_part = params.get('supplier_part', None)
|
||||
|
||||
if supplier_part:
|
||||
@ -818,7 +821,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 +846,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)
|
||||
|
||||
|
@ -4,7 +4,6 @@
|
||||
/* globals
|
||||
buildStatusDisplay,
|
||||
constructForm,
|
||||
getFieldByName,
|
||||
global_settings,
|
||||
imageHoverIcon,
|
||||
inventreeGet,
|
||||
@ -20,6 +19,7 @@
|
||||
*/
|
||||
|
||||
/* exported
|
||||
allocateStockToBuild,
|
||||
editBuildOrder,
|
||||
loadAllocationTable,
|
||||
loadBuildOrderAllocationTable,
|
||||
@ -102,6 +102,7 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
*/
|
||||
|
||||
var buildId = buildInfo.pk;
|
||||
var partId = buildInfo.part;
|
||||
|
||||
var outputId = 'untracked';
|
||||
|
||||
@ -120,11 +121,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
|
||||
var html = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
// "Auto" allocation only works for untracked stock items
|
||||
if (!output && lines > 0) {
|
||||
if (lines > 0) {
|
||||
html += makeIconButton(
|
||||
'fa-magic icon-blue', 'button-output-auto', outputId,
|
||||
'{% trans "Auto-allocate stock items to this output" %}',
|
||||
'fa-sign-in-alt icon-blue', 'button-output-auto', outputId,
|
||||
'{% trans "Allocate stock items to this build output" %}',
|
||||
);
|
||||
}
|
||||
|
||||
@ -136,7 +136,6 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (output) {
|
||||
|
||||
// Add a button to "complete" the particular build output
|
||||
@ -163,11 +162,17 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
||||
|
||||
// Add callbacks for the buttons
|
||||
$(panel).find(`#button-output-auto-${outputId}`).click(function() {
|
||||
|
||||
var bom_items = $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('getData');
|
||||
|
||||
// Launch modal dialog to perform auto-allocation
|
||||
launchModalForm(`/build/${buildId}/auto-allocate/`,
|
||||
allocateStockToBuild(
|
||||
buildId,
|
||||
partId,
|
||||
bom_items,
|
||||
{
|
||||
data: {
|
||||
},
|
||||
source_location: buildInfo.source_location,
|
||||
output: outputId,
|
||||
success: reloadTable,
|
||||
}
|
||||
);
|
||||
@ -344,18 +349,26 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
function requiredQuantity(row) {
|
||||
// Return the requied quantity for a given row
|
||||
|
||||
var quantity = 0;
|
||||
|
||||
if (output) {
|
||||
// "Tracked" parts are calculated against individual build outputs
|
||||
return row.quantity * output.quantity;
|
||||
quantity = row.quantity * output.quantity;
|
||||
} else {
|
||||
// "Untracked" parts are specified against the build itself
|
||||
return row.quantity * buildInfo.quantity;
|
||||
quantity = row.quantity * buildInfo.quantity;
|
||||
}
|
||||
|
||||
// Store the required quantity in the row data
|
||||
row.required = quantity;
|
||||
|
||||
return quantity;
|
||||
}
|
||||
|
||||
function sumAllocations(row) {
|
||||
// Calculat total allocations for a given row
|
||||
if (!row.allocations) {
|
||||
row.allocated = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@ -365,6 +378,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
quantity += item.quantity;
|
||||
});
|
||||
|
||||
row.allocated = quantity;
|
||||
|
||||
return quantity;
|
||||
}
|
||||
|
||||
@ -377,52 +392,28 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
// Primary key of the 'sub_part'
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
// Launch form to allocate new stock against this output
|
||||
launchModalForm('{% url "build-item-create" %}', {
|
||||
success: reloadTable,
|
||||
data: {
|
||||
part: pk,
|
||||
build: buildId,
|
||||
install_into: outputId,
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'stock_item',
|
||||
label: '{% trans "New Stock Item" %}',
|
||||
title: '{% trans "Create new Stock Item" %}',
|
||||
url: '{% url "stock-item-create" %}',
|
||||
data: {
|
||||
part: pk,
|
||||
},
|
||||
},
|
||||
// Extract BomItem information from this row
|
||||
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
|
||||
|
||||
if (!row) {
|
||||
console.log('WARNING: getRowByUniqueId returned null');
|
||||
return;
|
||||
}
|
||||
|
||||
allocateStockToBuild(
|
||||
buildId,
|
||||
partId,
|
||||
[
|
||||
row,
|
||||
],
|
||||
callback: [
|
||||
{
|
||||
field: 'stock_item',
|
||||
action: function(value) {
|
||||
inventreeGet(
|
||||
`/api/stock/${value}/`, {},
|
||||
{
|
||||
success: function(response) {
|
||||
|
||||
// How many items are actually available for the given stock item?
|
||||
var available = response.quantity - response.allocated;
|
||||
|
||||
var field = getFieldByName('#modal-form', 'quantity');
|
||||
|
||||
// Allocation quantity initial value
|
||||
var initial = field.attr('value');
|
||||
|
||||
if (available < initial) {
|
||||
field.val(available);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
{
|
||||
source_location: buildInfo.source_location,
|
||||
success: function(data) {
|
||||
$(table).bootstrapTable('refresh');
|
||||
},
|
||||
output: output == null ? null : output.pk,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Callback for 'buy' button
|
||||
@ -636,11 +627,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
text = `{% trans "Quantity" %}: ${row.quantity}`;
|
||||
}
|
||||
|
||||
{% if build.status == BuildStatus.COMPLETE %}
|
||||
url = `/stock/item/${row.pk}/`;
|
||||
{% else %}
|
||||
url = `/stock/item/${row.stock_item}/`;
|
||||
{% endif %}
|
||||
var pk = row.stock_item || row.pk;
|
||||
|
||||
url = `/stock/item/${pk}/`;
|
||||
|
||||
return renderLink(text, url);
|
||||
}
|
||||
@ -687,22 +676,31 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
// Assign button callbacks to the newly created allocation buttons
|
||||
subTable.find('.button-allocation-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
launchModalForm(`/build/item/${pk}/edit/`, {
|
||||
success: reloadTable,
|
||||
|
||||
constructForm(`/api/build/item/${pk}/`, {
|
||||
fields: {
|
||||
quantity: {},
|
||||
},
|
||||
title: '{% trans "Edit Allocation" %}',
|
||||
onSuccess: reloadTable,
|
||||
});
|
||||
});
|
||||
|
||||
subTable.find('.button-allocation-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
launchModalForm(`/build/item/${pk}/delete/`, {
|
||||
success: reloadTable,
|
||||
|
||||
constructForm(`/api/build/item/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Remove Allocation" %}',
|
||||
onSuccess: reloadTable,
|
||||
});
|
||||
});
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
visible: false,
|
||||
visible: true,
|
||||
switchable: false,
|
||||
checkbox: true,
|
||||
},
|
||||
{
|
||||
field: 'sub_part_detail.full_name',
|
||||
@ -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) {
|
||||
// Display a table of Build objects
|
||||
|
||||
|
@ -728,10 +728,17 @@ function updateFieldValues(fields, options) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Update the value of a named field
|
||||
*/
|
||||
function updateFieldValue(name, value, field, options) {
|
||||
var el = $(options.modal).find(`#id_${name}`);
|
||||
|
||||
if (!el) {
|
||||
console.log(`WARNING: updateFieldValue could not find field '${name}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
el.prop('checked', value);
|
||||
@ -864,6 +871,78 @@ function clearFormErrors(options) {
|
||||
$(options.modal).find('#non-field-errors').html('');
|
||||
}
|
||||
|
||||
/*
|
||||
* Display form error messages as returned from the server,
|
||||
* specifically for errors returned in an array.
|
||||
*
|
||||
* We need to know the unique ID of each item in the array,
|
||||
* and the array length must equal the length of the array returned from the server
|
||||
*
|
||||
* arguments:
|
||||
* - response: The JSON error response from the server
|
||||
* - parent: The name of the parent field e.g. "items"
|
||||
* - options: The global options struct
|
||||
*
|
||||
* options:
|
||||
* - nested: A map of nested ID values for the "parent" field
|
||||
* e.g.
|
||||
* {
|
||||
* "items": [
|
||||
* 1,
|
||||
* 2,
|
||||
* 12
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
*/
|
||||
|
||||
function handleNestedErrors(errors, field_name, options) {
|
||||
|
||||
var error_list = errors[field_name];
|
||||
|
||||
// Ignore null or empty list
|
||||
if (!error_list) {
|
||||
return;
|
||||
}
|
||||
|
||||
var nest_list = nest_list = options['nested'][field_name];
|
||||
|
||||
// Nest list must be provided!
|
||||
if (!nest_list) {
|
||||
console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (var idx = 0; idx < error_list.length; idx++) {
|
||||
|
||||
var error_item = error_list[idx];
|
||||
|
||||
if (idx >= nest_list.length) {
|
||||
console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract the particular ID of the nested item
|
||||
var nest_id = nest_list[idx];
|
||||
|
||||
// Here, error_item is a map of field names to error messages
|
||||
for (sub_field_name in error_item) {
|
||||
var errors = error_item[sub_field_name];
|
||||
|
||||
// Find the target (nested) field
|
||||
var target = `${field_name}_${sub_field_name}_${nest_id}`;
|
||||
|
||||
for (var ii = errors.length-1; ii >= 0; ii--) {
|
||||
|
||||
var error_text = errors[ii];
|
||||
|
||||
addFieldErrorMessage(target, error_text, ii, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Display form error messages as returned from the server.
|
||||
@ -913,28 +992,30 @@ function handleFormErrors(errors, fields, options) {
|
||||
|
||||
for (var field_name in errors) {
|
||||
|
||||
// Add the 'has-error' class
|
||||
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
|
||||
if (field_name in fields) {
|
||||
|
||||
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
|
||||
var field = fields[field_name];
|
||||
|
||||
var field_errors = errors[field_name];
|
||||
if ((field.type == 'field') && ('child' in field)) {
|
||||
// This is a "nested" field
|
||||
handleNestedErrors(errors, field_name, options);
|
||||
} else {
|
||||
// This is a "simple" field
|
||||
|
||||
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
|
||||
first_error_field = field_name;
|
||||
}
|
||||
var field_errors = errors[field_name];
|
||||
|
||||
// Add an entry for each returned error message
|
||||
for (var ii = field_errors.length-1; ii >= 0; ii--) {
|
||||
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
|
||||
first_error_field = field_name;
|
||||
}
|
||||
|
||||
var error_text = field_errors[ii];
|
||||
// Add an entry for each returned error message
|
||||
for (var ii = field_errors.length-1; ii >= 0; ii--) {
|
||||
|
||||
var error_html = `
|
||||
<span id='error_${ii+1}_id_${field_name}' class='help-block form-error-message'>
|
||||
<strong>${error_text}</strong>
|
||||
</span>`;
|
||||
var error_text = field_errors[ii];
|
||||
|
||||
field_dom.append(error_html);
|
||||
addFieldErrorMessage(field_name, error_text, ii, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -952,6 +1033,30 @@ function handleFormErrors(errors, fields, options) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Add a rendered error message to the provided field
|
||||
*/
|
||||
function addFieldErrorMessage(field_name, error_text, error_idx, options) {
|
||||
|
||||
// Add the 'has-error' class
|
||||
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
|
||||
|
||||
var field_dom = $(options.modal).find(`#errors-${field_name}`);
|
||||
|
||||
if (field_dom) {
|
||||
|
||||
var error_html = `
|
||||
<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) {
|
||||
|
||||
return $(options.modal).find(`#div_id_${field}`).is(':visible');
|
||||
@ -1007,7 +1112,14 @@ function addClearCallbacks(fields, options) {
|
||||
|
||||
function addClearCallback(name, field, options) {
|
||||
|
||||
$(options.modal).find(`#clear_${name}`).click(function() {
|
||||
var el = $(options.modal).find(`#clear_${name}`);
|
||||
|
||||
if (!el) {
|
||||
console.log(`WARNING: addClearCallback could not find field '${name}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
el.click(function() {
|
||||
updateFieldValue(name, null, field, options);
|
||||
});
|
||||
}
|
||||
@ -1168,7 +1280,7 @@ function addSecondaryModal(field, fields, options) {
|
||||
|
||||
|
||||
/*
|
||||
* Initializea single related-field
|
||||
* Initialize a single related-field
|
||||
*
|
||||
* argument:
|
||||
* - modal: DOM identifier for the modal window
|
||||
@ -1182,7 +1294,7 @@ function initializeRelatedField(field, fields, options) {
|
||||
|
||||
if (!field.api_url) {
|
||||
// TODO: Provide manual api_url option?
|
||||
console.log(`Related field '${name}' missing 'api_url' parameter.`);
|
||||
console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1203,6 +1315,15 @@ function initializeRelatedField(field, fields, options) {
|
||||
placeholder: '',
|
||||
dropdownParent: $(options.modal),
|
||||
dropdownAutoWidth: false,
|
||||
language: {
|
||||
noResults: function(query) {
|
||||
if (field.noResults) {
|
||||
return field.noResults(query);
|
||||
} else {
|
||||
return '{% trans "No results found" %}';
|
||||
}
|
||||
}
|
||||
},
|
||||
ajax: {
|
||||
url: field.api_url,
|
||||
dataType: 'json',
|
||||
@ -1225,6 +1346,11 @@ function initializeRelatedField(field, fields, options) {
|
||||
query.search = params.term;
|
||||
query.offset = offset;
|
||||
query.limit = pageSize;
|
||||
|
||||
// Allow custom run-time filter augmentation
|
||||
if ('adjustFilters' in field) {
|
||||
query = field.adjustFilters(query);
|
||||
}
|
||||
|
||||
return query;
|
||||
},
|
||||
@ -1319,6 +1445,7 @@ function initializeRelatedField(field, fields, options) {
|
||||
|
||||
// If a 'value' is already defined, grab the model info from the server
|
||||
if (field.value) {
|
||||
|
||||
var pk = field.value;
|
||||
var url = `${field.api_url}/${pk}/`.replace('//', '/');
|
||||
|
||||
@ -1327,6 +1454,24 @@ function initializeRelatedField(field, fields, options) {
|
||||
setRelatedFieldData(name, data, options);
|
||||
}
|
||||
});
|
||||
} else if (field.auto_fill) {
|
||||
// Attempt to auto-fill the field
|
||||
|
||||
var filters = field.filters || {};
|
||||
|
||||
// Enforce pagination, limit to a single return (for fast query)
|
||||
filters.limit = 1;
|
||||
filters.offset = 0;
|
||||
|
||||
inventreeGet(field.api_url, field.filters || {}, {
|
||||
success: function(data) {
|
||||
|
||||
// Only a single result is available, given the provided filters
|
||||
if (data.count == 1) {
|
||||
setRelatedFieldData(name, data.results[0], options);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1884,7 +2029,7 @@ function constructChoiceInput(name, parameters) {
|
||||
*/
|
||||
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
|
||||
|
||||
|
@ -65,7 +65,7 @@ function imageHoverIcon(url) {
|
||||
function thumbnailImage(url) {
|
||||
|
||||
if (!url) {
|
||||
url = '/static/img/blank_img.png';
|
||||
url = blankImage();
|
||||
}
|
||||
|
||||
// TODO: Support insertion of custom classes
|
||||
|
@ -37,7 +37,7 @@ function renderCompany(name, data, parameters, options) {
|
||||
|
||||
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;
|
||||
}
|
||||
@ -47,22 +47,59 @@ function renderCompany(name, data, parameters, options) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderStockItem(name, data, parameters, options) {
|
||||
|
||||
var image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
|
||||
|
||||
var html = `<img src='${image}' class='select2-thumbnail'>`;
|
||||
|
||||
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}`;
|
||||
var image = blankImage();
|
||||
|
||||
if (data.part_detail) {
|
||||
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
|
||||
}
|
||||
|
||||
if (data.part_detail.description) {
|
||||
var html = '';
|
||||
|
||||
var render_part_detail = true;
|
||||
|
||||
if ('render_part_detail' in parameters) {
|
||||
render_part_detail = parameters['render_part_detail'];
|
||||
}
|
||||
|
||||
if (render_part_detail) {
|
||||
html += `<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>`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -79,7 +116,7 @@ function renderStockLocation(name, data, parameters, options) {
|
||||
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;
|
||||
}
|
||||
@ -96,7 +133,7 @@ function renderBuild(name, data, parameters, options) {
|
||||
var html = select2Thumbnail(image);
|
||||
|
||||
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>`;
|
||||
|
||||
@ -116,7 +153,7 @@ function renderPart(name, data, parameters, options) {
|
||||
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;
|
||||
}
|
||||
@ -168,7 +205,7 @@ function renderPartCategory(name, data, parameters, options) {
|
||||
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;
|
||||
}
|
||||
@ -205,7 +242,7 @@ function renderManufacturerPart(name, data, parameters, options) {
|
||||
html += ` <span><b>${data.manufacturer_detail.name}</b> - ${data.MPN}</span>`;
|
||||
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;
|
||||
}
|
||||
@ -234,7 +271,7 @@ function renderSupplierPart(name, data, parameters, options) {
|
||||
html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`;
|
||||
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;
|
||||
|
17
tasks.py
17
tasks.py
@ -127,13 +127,20 @@ def worker(c):
|
||||
|
||||
|
||||
@task
|
||||
def rebuild(c):
|
||||
def rebuild_models(c):
|
||||
"""
|
||||
Rebuild database models with MPTT structures
|
||||
"""
|
||||
|
||||
manage(c, "rebuild_models")
|
||||
manage(c, "rebuild_models", pty=True)
|
||||
|
||||
@task
|
||||
def rebuild_thumbnails(c):
|
||||
"""
|
||||
Rebuild missing image thumbnails
|
||||
"""
|
||||
|
||||
manage(c, "rebuild_thumbnails", pty=True)
|
||||
|
||||
@task
|
||||
def clean_settings(c):
|
||||
@ -143,7 +150,7 @@ def clean_settings(c):
|
||||
|
||||
manage(c, "clean_settings")
|
||||
|
||||
@task(post=[rebuild])
|
||||
@task(post=[rebuild_models, rebuild_thumbnails])
|
||||
def migrate(c):
|
||||
"""
|
||||
Performs database migrations.
|
||||
@ -341,7 +348,7 @@ def export_records(c, filename='data.json'):
|
||||
print("Data export completed")
|
||||
|
||||
|
||||
@task(help={'filename': 'Input filename'}, post=[rebuild])
|
||||
@task(help={'filename': 'Input filename'}, post=[rebuild_models, rebuild_thumbnails])
|
||||
def import_records(c, filename='data.json'):
|
||||
"""
|
||||
Import database records from a file
|
||||
@ -399,7 +406,7 @@ def delete_data(c, force=False):
|
||||
manage(c, 'flush')
|
||||
|
||||
|
||||
@task(post=[rebuild])
|
||||
@task(post=[rebuild_models, rebuild_thumbnails])
|
||||
def import_fixtures(c):
|
||||
"""
|
||||
Import fixture data into the database.
|
||||
|
Loading…
Reference in New Issue
Block a user