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)
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def options(self, url, expected_code=None):
|
||||||
|
"""
|
||||||
|
Issue an OPTIONS request
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.options(url, format='json')
|
||||||
|
|
||||||
|
if expected_code is not None:
|
||||||
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
|
||||||
|
return response
|
||||||
|
@ -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_SW_VERSION = "0.6.0 dev"
|
||||||
|
|
||||||
INVENTREE_API_VERSION = 12
|
INVENTREE_API_VERSION = 13
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v13 -> 2021-10-05
|
||||||
|
- Adds API endpoint to allocate stock items against a BuildOrder
|
||||||
|
- Updates StockItem API with improved filtering against BomItem data
|
||||||
|
|
||||||
v12 -> 2021-09-07
|
v12 -> 2021-09-07
|
||||||
- Adds API endpoint to receive stock items against a PurchaseOrder
|
- Adds API endpoint to receive stock items against a PurchaseOrder
|
||||||
|
|
||||||
|
@ -5,10 +5,12 @@ JSON API for the Build app
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
from rest_framework import filters
|
from rest_framework import filters, generics
|
||||||
from rest_framework import generics
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
@ -19,6 +21,7 @@ from InvenTree.status_codes import BuildStatus
|
|||||||
|
|
||||||
from .models import Build, BuildItem, BuildOrderAttachment
|
from .models import Build, BuildItem, BuildOrderAttachment
|
||||||
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
|
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
|
||||||
|
from .serializers import BuildAllocationSerializer
|
||||||
|
|
||||||
|
|
||||||
class BuildFilter(rest_filters.FilterSet):
|
class BuildFilter(rest_filters.FilterSet):
|
||||||
@ -92,7 +95,7 @@ class BuildList(generics.ListCreateAPIView):
|
|||||||
as some of the fields don't natively play nicely with DRF
|
as some of the fields don't natively play nicely with DRF
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = super().get_queryset().prefetch_related('part')
|
queryset = super().get_queryset().select_related('part')
|
||||||
|
|
||||||
queryset = BuildSerializer.annotate_queryset(queryset)
|
queryset = BuildSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
@ -181,6 +184,58 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
|
|||||||
serializer_class = BuildSerializer
|
serializer_class = BuildSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class BuildAllocate(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint to allocate stock items to a build order
|
||||||
|
|
||||||
|
- The BuildOrder object is specified by the URL
|
||||||
|
- Items to allocate are specified as a list called "items" with the following options:
|
||||||
|
- bom_item: pk value of a given BomItem object (must match the part associated with this build)
|
||||||
|
- stock_item: pk value of a given StockItem object
|
||||||
|
- quantity: quantity to allocate
|
||||||
|
- output: StockItem (build order output) to allocate stock against (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
|
serializer_class = BuildAllocationSerializer
|
||||||
|
|
||||||
|
def get_build(self):
|
||||||
|
"""
|
||||||
|
Returns the BuildOrder associated with this API endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
pk = self.kwargs.get('pk', None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
build = Build.objects.get(pk=pk)
|
||||||
|
except (Build.DoesNotExist, ValueError):
|
||||||
|
raise ValidationError(_("Matching build order does not exist"))
|
||||||
|
|
||||||
|
return build
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
"""
|
||||||
|
Provide the Build object to the serializer context
|
||||||
|
"""
|
||||||
|
|
||||||
|
context = super().get_serializer_context()
|
||||||
|
|
||||||
|
context['build'] = self.get_build()
|
||||||
|
context['request'] = self.request
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for detail view of a BuildItem object
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = BuildItem.objects.all()
|
||||||
|
serializer_class = BuildItemSerializer
|
||||||
|
|
||||||
|
|
||||||
class BuildItemList(generics.ListCreateAPIView):
|
class BuildItemList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of BuildItem objects
|
""" API endpoint for accessing a list of BuildItem objects
|
||||||
|
|
||||||
@ -210,9 +265,9 @@ class BuildItemList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
query = BuildItem.objects.all()
|
query = BuildItem.objects.all()
|
||||||
|
|
||||||
query = query.select_related('stock_item')
|
query = query.select_related('stock_item__location')
|
||||||
query = query.prefetch_related('stock_item__part')
|
query = query.select_related('stock_item__part')
|
||||||
query = query.prefetch_related('stock_item__part__category')
|
query = query.select_related('stock_item__part__category')
|
||||||
|
|
||||||
return query
|
return query
|
||||||
|
|
||||||
@ -282,16 +337,20 @@ build_api_urls = [
|
|||||||
# Attachments
|
# Attachments
|
||||||
url(r'^attachment/', include([
|
url(r'^attachment/', include([
|
||||||
url(r'^(?P<pk>\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
|
url(r'^(?P<pk>\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
|
||||||
url('^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
|
url(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
# Build Items
|
# Build Items
|
||||||
url(r'^item/', include([
|
url(r'^item/', include([
|
||||||
url('^.*$', BuildItemList.as_view(), name='api-build-item-list')
|
url(r'^(?P<pk>\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'),
|
||||||
|
url(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
# Build Detail
|
# Build Detail
|
||||||
url(r'^(?P<pk>\d+)/', BuildDetail.as_view(), name='api-build-detail'),
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
|
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||||
|
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
# Build List
|
# Build List
|
||||||
url(r'^.*$', BuildList.as_view(), name='api-build-list'),
|
url(r'^.*$', BuildList.as_view(), name='api-build-list'),
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
- model: build.build
|
- model: build.build
|
||||||
pk: 1
|
pk: 1
|
||||||
fields:
|
fields:
|
||||||
part: 25
|
part: 100 # Build against part 100 "Bob"
|
||||||
batch: 'B1'
|
batch: 'B1'
|
||||||
reference: "0001"
|
reference: "0001"
|
||||||
title: 'Building 7 parts'
|
title: 'Building 7 parts'
|
||||||
|
@ -15,7 +15,7 @@ from InvenTree.fields import DatePickerFormField
|
|||||||
|
|
||||||
from InvenTree.status_codes import StockStatus
|
from InvenTree.status_codes import StockStatus
|
||||||
|
|
||||||
from .models import Build, BuildItem
|
from .models import Build
|
||||||
|
|
||||||
from stock.models import StockLocation, StockItem
|
from stock.models import StockLocation, StockItem
|
||||||
|
|
||||||
@ -163,18 +163,6 @@ class UnallocateBuildForm(HelperForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class AutoAllocateForm(HelperForm):
|
|
||||||
""" Form for auto-allocation of stock to a build """
|
|
||||||
|
|
||||||
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation'))
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Build
|
|
||||||
fields = [
|
|
||||||
'confirm',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class CompleteBuildForm(HelperForm):
|
class CompleteBuildForm(HelperForm):
|
||||||
"""
|
"""
|
||||||
Form for marking a build as complete
|
Form for marking a build as complete
|
||||||
@ -256,22 +244,3 @@ class CancelBuildForm(HelperForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'confirm_cancel'
|
'confirm_cancel'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class EditBuildItemForm(HelperForm):
|
|
||||||
"""
|
|
||||||
Form for creating (or editing) a BuildItem object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'), help_text=_('Select quantity of stock to allocate'))
|
|
||||||
|
|
||||||
part_id = forms.IntegerField(required=False, widget=forms.HiddenInput())
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = BuildItem
|
|
||||||
fields = [
|
|
||||||
'build',
|
|
||||||
'stock_item',
|
|
||||||
'quantity',
|
|
||||||
'install_into',
|
|
||||||
]
|
|
||||||
|
@ -4,12 +4,14 @@ Build database model definitions
|
|||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
import decimal
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -584,86 +586,6 @@ class Build(MPTTModel):
|
|||||||
self.status = BuildStatus.CANCELLED
|
self.status = BuildStatus.CANCELLED
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def getAutoAllocations(self):
|
|
||||||
"""
|
|
||||||
Return a list of StockItem objects which will be allocated
|
|
||||||
using the 'AutoAllocate' function.
|
|
||||||
|
|
||||||
For each item in the BOM for the attached Part,
|
|
||||||
the following tests must *all* evaluate to True,
|
|
||||||
for the part to be auto-allocated:
|
|
||||||
|
|
||||||
- The sub_item in the BOM line must *not* be trackable
|
|
||||||
- There is only a single stock item available (which has not already been allocated to this build)
|
|
||||||
- The stock item has an availability greater than zero
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A list object containing the StockItem objects to be allocated (and the quantities).
|
|
||||||
Each item in the list is a dict as follows:
|
|
||||||
{
|
|
||||||
'stock_item': stock_item,
|
|
||||||
'quantity': stock_quantity,
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
allocations = []
|
|
||||||
|
|
||||||
"""
|
|
||||||
Iterate through each item in the BOM
|
|
||||||
"""
|
|
||||||
|
|
||||||
for bom_item in self.bom_items:
|
|
||||||
|
|
||||||
part = bom_item.sub_part
|
|
||||||
|
|
||||||
# If the part is "trackable" it cannot be auto-allocated
|
|
||||||
if part.trackable:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip any parts which are already fully allocated
|
|
||||||
if self.isPartFullyAllocated(part, None):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# How many parts are required to complete the output?
|
|
||||||
required = self.unallocatedQuantity(part, None)
|
|
||||||
|
|
||||||
# Grab a list of stock items which are available
|
|
||||||
stock_items = self.availableStockItems(part, None)
|
|
||||||
|
|
||||||
# Ensure that the available stock items are in the correct location
|
|
||||||
if self.take_from is not None:
|
|
||||||
# Filter for stock that is located downstream of the designated location
|
|
||||||
stock_items = stock_items.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()])
|
|
||||||
|
|
||||||
# Only one StockItem to choose from? Default to that one!
|
|
||||||
if stock_items.count() == 1:
|
|
||||||
stock_item = stock_items[0]
|
|
||||||
|
|
||||||
# Double check that we have not already allocated this stock-item against this build
|
|
||||||
build_items = BuildItem.objects.filter(
|
|
||||||
build=self,
|
|
||||||
stock_item=stock_item,
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(build_items) > 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# How many items are actually available?
|
|
||||||
if stock_item.quantity > 0:
|
|
||||||
|
|
||||||
# Only take as many as are available
|
|
||||||
if stock_item.quantity < required:
|
|
||||||
required = stock_item.quantity
|
|
||||||
|
|
||||||
allocation = {
|
|
||||||
'stock_item': stock_item,
|
|
||||||
'quantity': required,
|
|
||||||
}
|
|
||||||
|
|
||||||
allocations.append(allocation)
|
|
||||||
|
|
||||||
return allocations
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def unallocateOutput(self, output, part=None):
|
def unallocateOutput(self, output, part=None):
|
||||||
"""
|
"""
|
||||||
@ -803,37 +725,6 @@ class Build(MPTTModel):
|
|||||||
# Remove the build output from the database
|
# Remove the build output from the database
|
||||||
output.delete()
|
output.delete()
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def autoAllocate(self):
|
|
||||||
"""
|
|
||||||
Run auto-allocation routine to allocate StockItems to this Build.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
output: If specified, only auto-allocate against the given built output
|
|
||||||
|
|
||||||
Returns a list of dict objects with keys like:
|
|
||||||
|
|
||||||
{
|
|
||||||
'stock_item': item,
|
|
||||||
'quantity': quantity,
|
|
||||||
}
|
|
||||||
|
|
||||||
See: getAutoAllocations()
|
|
||||||
"""
|
|
||||||
|
|
||||||
allocations = self.getAutoAllocations()
|
|
||||||
|
|
||||||
for item in allocations:
|
|
||||||
# Create a new allocation
|
|
||||||
build_item = BuildItem(
|
|
||||||
build=self,
|
|
||||||
stock_item=item['stock_item'],
|
|
||||||
quantity=item['quantity'],
|
|
||||||
install_into=None
|
|
||||||
)
|
|
||||||
|
|
||||||
build_item.save()
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def subtractUntrackedStock(self, user):
|
def subtractUntrackedStock(self, user):
|
||||||
"""
|
"""
|
||||||
@ -1165,8 +1056,10 @@ class BuildItem(models.Model):
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
build: Link to a Build object
|
build: Link to a Build object
|
||||||
|
bom_item: Link to a BomItem object (may or may not point to the same part as the build)
|
||||||
stock_item: Link to a StockItem object
|
stock_item: Link to a StockItem object
|
||||||
quantity: Number of units allocated
|
quantity: Number of units allocated
|
||||||
|
install_into: Destination stock item (or None)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -1185,35 +1078,13 @@ class BuildItem(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
self.validate_unique()
|
|
||||||
self.clean()
|
self.clean()
|
||||||
|
|
||||||
super().save()
|
super().save()
|
||||||
|
|
||||||
def validate_unique(self, exclude=None):
|
|
||||||
"""
|
|
||||||
Test that this BuildItem object is "unique".
|
|
||||||
Essentially we do not want a stock_item being allocated to a Build multiple times.
|
|
||||||
"""
|
|
||||||
|
|
||||||
super().validate_unique(exclude)
|
|
||||||
|
|
||||||
items = BuildItem.objects.exclude(id=self.id).filter(
|
|
||||||
build=self.build,
|
|
||||||
stock_item=self.stock_item,
|
|
||||||
install_into=self.install_into
|
|
||||||
)
|
|
||||||
|
|
||||||
if items.exists():
|
|
||||||
msg = _("BuildItem must be unique for build, stock_item and install_into")
|
|
||||||
raise ValidationError({
|
|
||||||
'build': msg,
|
|
||||||
'stock_item': msg,
|
|
||||||
'install_into': msg
|
|
||||||
})
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
""" Check validity of the BuildItem model.
|
"""
|
||||||
|
Check validity of this BuildItem instance.
|
||||||
The following checks are performed:
|
The following checks are performed:
|
||||||
|
|
||||||
- StockItem.part must be in the BOM of the Part object referenced by Build
|
- StockItem.part must be in the BOM of the Part object referenced by Build
|
||||||
@ -1224,8 +1095,6 @@ class BuildItem(models.Model):
|
|||||||
|
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
errors = {}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
# If the 'part' is trackable, then the 'install_into' field must be set!
|
# If the 'part' is trackable, then the 'install_into' field must be set!
|
||||||
@ -1234,29 +1103,39 @@ class BuildItem(models.Model):
|
|||||||
|
|
||||||
# Allocated quantity cannot exceed available stock quantity
|
# Allocated quantity cannot exceed available stock quantity
|
||||||
if self.quantity > self.stock_item.quantity:
|
if self.quantity > self.stock_item.quantity:
|
||||||
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format(
|
|
||||||
n=normalize(self.quantity),
|
q = normalize(self.quantity)
|
||||||
q=normalize(self.stock_item.quantity)
|
a = normalize(self.stock_item.quantity)
|
||||||
)]
|
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})')
|
||||||
|
})
|
||||||
|
|
||||||
# Allocated quantity cannot cause the stock item to be over-allocated
|
# Allocated quantity cannot cause the stock item to be over-allocated
|
||||||
if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity:
|
available = decimal.Decimal(self.stock_item.quantity)
|
||||||
errors['quantity'] = _('StockItem is over-allocated')
|
allocated = decimal.Decimal(self.stock_item.allocation_count())
|
||||||
|
quantity = decimal.Decimal(self.quantity)
|
||||||
|
|
||||||
|
if available - allocated + quantity < quantity:
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': _('Stock item is over-allocated')
|
||||||
|
})
|
||||||
|
|
||||||
# Allocated quantity must be positive
|
# Allocated quantity must be positive
|
||||||
if self.quantity <= 0:
|
if self.quantity <= 0:
|
||||||
errors['quantity'] = _('Allocation quantity must be greater than zero')
|
raise ValidationError({
|
||||||
|
'quantity': _('Allocation quantity must be greater than zero'),
|
||||||
|
})
|
||||||
|
|
||||||
# Quantity must be 1 for serialized stock
|
# Quantity must be 1 for serialized stock
|
||||||
if self.stock_item.serialized and not self.quantity == 1:
|
if self.stock_item.serialized and not self.quantity == 1:
|
||||||
errors['quantity'] = _('Quantity must be 1 for serialized stock')
|
raise ValidationError({
|
||||||
|
'quantity': _('Quantity must be 1 for serialized stock')
|
||||||
|
})
|
||||||
|
|
||||||
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
|
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if len(errors) > 0:
|
|
||||||
raise ValidationError(errors)
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Attempt to find the "BomItem" which links this BuildItem to the build.
|
Attempt to find the "BomItem" which links this BuildItem to the build.
|
||||||
|
|
||||||
@ -1269,7 +1148,7 @@ class BuildItem(models.Model):
|
|||||||
"""
|
"""
|
||||||
A BomItem object has already been assigned. This is valid if:
|
A BomItem object has already been assigned. This is valid if:
|
||||||
|
|
||||||
a) It points to the same "part" as the referened build
|
a) It points to the same "part" as the referenced build
|
||||||
b) Either:
|
b) Either:
|
||||||
i) The sub_part points to the same part as the referenced StockItem
|
i) The sub_part points to the same part as the referenced StockItem
|
||||||
ii) The BomItem allows variants and the part referenced by the StockItem
|
ii) The BomItem allows variants and the part referenced by the StockItem
|
||||||
@ -1309,7 +1188,7 @@ class BuildItem(models.Model):
|
|||||||
if not bom_item_valid:
|
if not bom_item_valid:
|
||||||
|
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'stock_item': _("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)
|
'stock_item': _("Selected stock item not found in BOM")
|
||||||
})
|
})
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
@ -5,16 +5,25 @@ JSON serializers for Build API
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from django.db.models import Case, When, Value
|
from django.db.models import Case, When, Value
|
||||||
from django.db.models import BooleanField
|
from django.db.models import BooleanField
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
|
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
|
||||||
|
|
||||||
from stock.serializers import StockItemSerializerBrief
|
import InvenTree.helpers
|
||||||
from stock.serializers import LocationSerializer
|
|
||||||
|
from stock.models import StockItem
|
||||||
|
from stock.serializers import StockItemSerializerBrief, LocationSerializer
|
||||||
|
|
||||||
|
from part.models import BomItem
|
||||||
from part.serializers import PartSerializer, PartBriefSerializer
|
from part.serializers import PartSerializer, PartBriefSerializer
|
||||||
from users.serializers import OwnerSerializer
|
from users.serializers import OwnerSerializer
|
||||||
|
|
||||||
@ -22,7 +31,9 @@ from .models import Build, BuildItem, BuildOrderAttachment
|
|||||||
|
|
||||||
|
|
||||||
class BuildSerializer(InvenTreeModelSerializer):
|
class BuildSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializes a Build object """
|
"""
|
||||||
|
Serializes a Build object
|
||||||
|
"""
|
||||||
|
|
||||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
@ -109,6 +120,170 @@ class BuildSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BuildAllocationItemSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
A serializer for allocating a single stock item against a build order
|
||||||
|
"""
|
||||||
|
|
||||||
|
bom_item = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=BomItem.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=False,
|
||||||
|
required=True,
|
||||||
|
label=_('BOM Item'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_bom_item(self, bom_item):
|
||||||
|
|
||||||
|
build = self.context['build']
|
||||||
|
|
||||||
|
# BomItem must point to the same 'part' as the parent build
|
||||||
|
if build.part != bom_item.part:
|
||||||
|
raise ValidationError(_("bom_item.part must point to the same part as the build order"))
|
||||||
|
|
||||||
|
return bom_item
|
||||||
|
|
||||||
|
stock_item = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=StockItem.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=False,
|
||||||
|
required=True,
|
||||||
|
label=_('Stock Item'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_stock_item(self, stock_item):
|
||||||
|
|
||||||
|
if not stock_item.in_stock:
|
||||||
|
raise ValidationError(_("Item must be in stock"))
|
||||||
|
|
||||||
|
return stock_item
|
||||||
|
|
||||||
|
quantity = serializers.DecimalField(
|
||||||
|
max_digits=15,
|
||||||
|
decimal_places=5,
|
||||||
|
min_value=0,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_quantity(self, quantity):
|
||||||
|
|
||||||
|
if quantity <= 0:
|
||||||
|
raise ValidationError(_("Quantity must be greater than zero"))
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
|
output = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=StockItem.objects.filter(is_building=True),
|
||||||
|
many=False,
|
||||||
|
allow_null=True,
|
||||||
|
required=False,
|
||||||
|
label=_('Build Output'),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'bom_item',
|
||||||
|
'stock_item',
|
||||||
|
'quantity',
|
||||||
|
'output',
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
|
||||||
|
super().validate(data)
|
||||||
|
|
||||||
|
bom_item = data['bom_item']
|
||||||
|
stock_item = data['stock_item']
|
||||||
|
quantity = data['quantity']
|
||||||
|
output = data.get('output', None)
|
||||||
|
|
||||||
|
# build = self.context['build']
|
||||||
|
|
||||||
|
# TODO: Check that the "stock item" is valid for the referenced "sub_part"
|
||||||
|
# Note: Because of allow_variants options, it may not be a direct match!
|
||||||
|
|
||||||
|
# Check that the quantity does not exceed the available amount from the stock item
|
||||||
|
q = stock_item.unallocated_quantity()
|
||||||
|
|
||||||
|
if quantity > q:
|
||||||
|
|
||||||
|
q = InvenTree.helpers.clean_decimal(q)
|
||||||
|
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': _(f"Available quantity ({q}) exceeded")
|
||||||
|
})
|
||||||
|
|
||||||
|
# Output *must* be set for trackable parts
|
||||||
|
if output is None and bom_item.sub_part.trackable:
|
||||||
|
raise ValidationError({
|
||||||
|
'output': _('Build output must be specified for allocation of tracked parts')
|
||||||
|
})
|
||||||
|
|
||||||
|
# Output *cannot* be set for un-tracked parts
|
||||||
|
if output is not None and not bom_item.sub_part.trackable:
|
||||||
|
|
||||||
|
raise ValidationError({
|
||||||
|
'output': _('Build output cannot be specified for allocation of untracked parts')
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class BuildAllocationSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
DRF serializer for allocation stock items against a build order
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = BuildAllocationItemSerializer(many=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'items',
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
"""
|
||||||
|
Validation
|
||||||
|
"""
|
||||||
|
|
||||||
|
super().validate(data)
|
||||||
|
|
||||||
|
items = data.get('items', [])
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
raise ValidationError(_('Allocation items must be provided'))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
items = data.get('items', [])
|
||||||
|
|
||||||
|
build = self.context['build']
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for item in items:
|
||||||
|
bom_item = item['bom_item']
|
||||||
|
stock_item = item['stock_item']
|
||||||
|
quantity = item['quantity']
|
||||||
|
output = item.get('output', None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a new BuildItem to allocate stock
|
||||||
|
BuildItem.objects.create(
|
||||||
|
build=build,
|
||||||
|
bom_item=bom_item,
|
||||||
|
stock_item=stock_item,
|
||||||
|
quantity=quantity,
|
||||||
|
install_into=output
|
||||||
|
)
|
||||||
|
except (ValidationError, DjangoValidationError) as exc:
|
||||||
|
# Catch model errors and re-throw as DRF errors
|
||||||
|
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||||
|
|
||||||
|
|
||||||
class BuildItemSerializer(InvenTreeModelSerializer):
|
class BuildItemSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializes a BuildItem object """
|
""" Serializes a BuildItem object """
|
||||||
|
|
||||||
|
@ -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 %}
|
{% if build.active %}
|
||||||
<div class='btn-group' role='group'>
|
<div class='btn-group' role='group'>
|
||||||
<button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
|
<button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
|
||||||
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
|
<span class='fas fa-sign-in-alt'></span> {% trans "Allocate Stock" %}
|
||||||
</button>
|
</button>
|
||||||
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
||||||
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
||||||
@ -191,7 +191,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<table class='table table-striped table-condensed' id='allocation-table-untracked'></table>
|
<div id='unallocated-toolbar'>
|
||||||
|
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||||
|
<div class='btn-group'>
|
||||||
|
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
|
||||||
|
<span class='fas fa-sign-in-alt'></span>
|
||||||
|
</button>
|
||||||
|
<div class='filter-list' id='filter-list-build-items'>
|
||||||
|
<!-- Empty div for table filters-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class='table table-striped table-condensed' id='allocation-table-untracked' data-toolbar='#unallocated-toolbar'></table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
{% trans "This Build Order does not have any associated untracked BOM items" %}
|
{% trans "This Build Order does not have any associated untracked BOM items" %}
|
||||||
@ -306,6 +318,9 @@ var buildInfo = {
|
|||||||
quantity: {{ build.quantity }},
|
quantity: {{ build.quantity }},
|
||||||
completed: {{ build.completed }},
|
completed: {{ build.completed }},
|
||||||
part: {{ build.part.pk }},
|
part: {{ build.part.pk }},
|
||||||
|
{% if build.take_from %}
|
||||||
|
source_location: {{ build.take_from.pk }},
|
||||||
|
{% endif %}
|
||||||
};
|
};
|
||||||
|
|
||||||
{% for item in build.incomplete_outputs %}
|
{% for item in build.incomplete_outputs %}
|
||||||
@ -401,13 +416,6 @@ $('#edit-notes').click(function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
var buildInfo = {
|
|
||||||
pk: {{ build.pk }},
|
|
||||||
quantity: {{ build.quantity }},
|
|
||||||
completed: {{ build.completed }},
|
|
||||||
part: {{ build.part.pk }},
|
|
||||||
};
|
|
||||||
|
|
||||||
{% if build.has_untracked_bom_items %}
|
{% if build.has_untracked_bom_items %}
|
||||||
// Load allocation table for un-tracked parts
|
// Load allocation table for un-tracked parts
|
||||||
loadBuildOutputAllocationTable(buildInfo, null);
|
loadBuildOutputAllocationTable(buildInfo, null);
|
||||||
@ -419,12 +427,38 @@ function reloadTable() {
|
|||||||
|
|
||||||
{% if build.active %}
|
{% if build.active %}
|
||||||
$("#btn-auto-allocate").on('click', function() {
|
$("#btn-auto-allocate").on('click', function() {
|
||||||
launchModalForm(
|
|
||||||
"{% url 'build-auto-allocate' build.id %}",
|
var bom_items = $("#allocation-table-untracked").bootstrapTable("getData");
|
||||||
{
|
|
||||||
success: reloadTable,
|
var incomplete_bom_items = [];
|
||||||
|
|
||||||
|
bom_items.forEach(function(bom_item) {
|
||||||
|
if (bom_item.required > bom_item.allocated) {
|
||||||
|
incomplete_bom_items.push(bom_item);
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
|
if (incomplete_bom_items.length == 0) {
|
||||||
|
showAlertDialog(
|
||||||
|
'{% trans "Allocation Complete" %}',
|
||||||
|
'{% trans "All untracked stock items have been allocated" %}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
|
||||||
|
allocateStockToBuild(
|
||||||
|
{{ build.pk }},
|
||||||
|
{{ build.part.pk }},
|
||||||
|
incomplete_bom_items,
|
||||||
|
{
|
||||||
|
{% if build.take_from %}
|
||||||
|
source_location: {{ build.take_from.pk }},
|
||||||
|
{% endif %}
|
||||||
|
success: function(data) {
|
||||||
|
$('#allocation-table-untracked').bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#btn-unallocate').on('click', function() {
|
$('#btn-unallocate').on('click', function() {
|
||||||
@ -436,6 +470,25 @@ $('#btn-unallocate').on('click', function() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#allocate-selected-items').click(function() {
|
||||||
|
|
||||||
|
var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections");
|
||||||
|
|
||||||
|
allocateStockToBuild(
|
||||||
|
{{ build.pk }},
|
||||||
|
{{ build.part.pk }},
|
||||||
|
bom_items,
|
||||||
|
{
|
||||||
|
{% if build.take_from %}
|
||||||
|
source_location: {{ build.take_from.pk }},
|
||||||
|
{% endif %}
|
||||||
|
success: function(data) {
|
||||||
|
$('#allocation-table-untracked').bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
$("#btn-order-parts").click(function() {
|
$("#btn-order-parts").click(function() {
|
||||||
launchModalForm("/order/purchase-order/order-parts/", {
|
launchModalForm("/order/purchase-order/order-parts/", {
|
||||||
data: {
|
data: {
|
||||||
|
@ -6,7 +6,7 @@ from datetime import datetime, timedelta
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from build.models import Build
|
from build.models import Build, BuildItem
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus
|
from InvenTree.status_codes import BuildStatus
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
@ -23,6 +23,7 @@ class BuildAPITest(InvenTreeAPITestCase):
|
|||||||
'location',
|
'location',
|
||||||
'bom',
|
'bom',
|
||||||
'build',
|
'build',
|
||||||
|
'stock',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Required roles to access Build API endpoints
|
# Required roles to access Build API endpoints
|
||||||
@ -36,6 +37,192 @@ class BuildAPITest(InvenTreeAPITestCase):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
|
|
||||||
|
class BuildAllocationTest(BuildAPITest):
|
||||||
|
"""
|
||||||
|
Unit tests for allocation of stock items against a build order.
|
||||||
|
|
||||||
|
For this test, we will be using Build ID=1;
|
||||||
|
|
||||||
|
- This points to Part 100 (see fixture data in part.yaml)
|
||||||
|
- This Part already has a BOM with 4 items (see fixture data in bom.yaml)
|
||||||
|
- There are no BomItem objects yet created for this build
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.assignRole('build.add')
|
||||||
|
self.assignRole('build.change')
|
||||||
|
|
||||||
|
self.url = reverse('api-build-allocate', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
self.build = Build.objects.get(pk=1)
|
||||||
|
|
||||||
|
# Record number of build items which exist at the start of each test
|
||||||
|
self.n = BuildItem.objects.count()
|
||||||
|
|
||||||
|
def test_build_data(self):
|
||||||
|
"""
|
||||||
|
Check that our assumptions about the particular BuildOrder are correct
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.assertEqual(self.build.part.pk, 100)
|
||||||
|
|
||||||
|
# There should be 4x BOM items we can use
|
||||||
|
self.assertEqual(self.build.part.bom_items.count(), 4)
|
||||||
|
|
||||||
|
# No items yet allocated to this build
|
||||||
|
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||||
|
|
||||||
|
def test_get(self):
|
||||||
|
"""
|
||||||
|
A GET request to the endpoint should return an error
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.get(self.url, expected_code=405)
|
||||||
|
|
||||||
|
def test_options(self):
|
||||||
|
"""
|
||||||
|
An OPTIONS request to the endpoint should return information about the endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.options(self.url, expected_code=200)
|
||||||
|
|
||||||
|
self.assertIn("API endpoint to allocate stock items to a build order", str(response.data))
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
"""
|
||||||
|
Test without any POST data
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Initially test with an empty data set
|
||||||
|
data = self.post(self.url, {}, expected_code=400).data
|
||||||
|
|
||||||
|
self.assertIn('This field is required', str(data['items']))
|
||||||
|
|
||||||
|
# Now test but with an empty items list
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Allocation items must be provided', str(data))
|
||||||
|
|
||||||
|
# No new BuildItem objects have been created during this test
|
||||||
|
self.assertEqual(self.n, BuildItem.objects.count())
|
||||||
|
|
||||||
|
def test_missing(self):
|
||||||
|
"""
|
||||||
|
Test with missing data
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Missing quantity
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"bom_item": 1, # M2x4 LPHS
|
||||||
|
"stock_item": 2, # 5,000 screws available
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('This field is required', str(data["items"][0]["quantity"]))
|
||||||
|
|
||||||
|
# Missing bom_item
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"stock_item": 2,
|
||||||
|
"quantity": 5000,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn("This field is required", str(data["items"][0]["bom_item"]))
|
||||||
|
|
||||||
|
# Missing stock_item
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"bom_item": 1,
|
||||||
|
"quantity": 5000,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn("This field is required", str(data["items"][0]["stock_item"]))
|
||||||
|
|
||||||
|
# No new BuildItem objects have been created during this test
|
||||||
|
self.assertEqual(self.n, BuildItem.objects.count())
|
||||||
|
|
||||||
|
def test_invalid_bom_item(self):
|
||||||
|
"""
|
||||||
|
Test by passing an invalid BOM item
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"bom_item": 5,
|
||||||
|
"stock_item": 11,
|
||||||
|
"quantity": 500,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('must point to the same part', str(data))
|
||||||
|
|
||||||
|
def test_valid_data(self):
|
||||||
|
"""
|
||||||
|
Test with valid data.
|
||||||
|
This should result in creation of a new BuildItem object
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.post(
|
||||||
|
self.url,
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"bom_item": 1,
|
||||||
|
"stock_item": 2,
|
||||||
|
"quantity": 5000,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
# A new BuildItem should have been created
|
||||||
|
self.assertEqual(self.n + 1, BuildItem.objects.count())
|
||||||
|
|
||||||
|
allocation = BuildItem.objects.last()
|
||||||
|
|
||||||
|
self.assertEqual(allocation.quantity, 5000)
|
||||||
|
self.assertEqual(allocation.bom_item.pk, 1)
|
||||||
|
self.assertEqual(allocation.stock_item.pk, 2)
|
||||||
|
|
||||||
|
|
||||||
class BuildListTest(BuildAPITest):
|
class BuildListTest(BuildAPITest):
|
||||||
"""
|
"""
|
||||||
Tests for the BuildOrder LIST API
|
Tests for the BuildOrder LIST API
|
||||||
|
@ -269,25 +269,6 @@ class BuildTest(TestCase):
|
|||||||
|
|
||||||
self.assertTrue(self.build.areUntrackedPartsFullyAllocated())
|
self.assertTrue(self.build.areUntrackedPartsFullyAllocated())
|
||||||
|
|
||||||
def test_auto_allocate(self):
|
|
||||||
"""
|
|
||||||
Test auto-allocation functionality against the build outputs.
|
|
||||||
|
|
||||||
Note: auto-allocations only work for un-tracked stock!
|
|
||||||
"""
|
|
||||||
|
|
||||||
allocations = self.build.getAutoAllocations()
|
|
||||||
|
|
||||||
self.assertEqual(len(allocations), 1)
|
|
||||||
|
|
||||||
self.build.autoAllocate()
|
|
||||||
self.assertEqual(BuildItem.objects.count(), 1)
|
|
||||||
|
|
||||||
# Check that one un-tracked part has been fully allocated to the build
|
|
||||||
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, None))
|
|
||||||
|
|
||||||
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, None))
|
|
||||||
|
|
||||||
def test_cancel(self):
|
def test_cancel(self):
|
||||||
"""
|
"""
|
||||||
Test cancellation of the build
|
Test cancellation of the build
|
||||||
|
@ -172,7 +172,7 @@ class TestBuildAPI(APITestCase):
|
|||||||
|
|
||||||
# Filter by 'part' status
|
# Filter by 'part' status
|
||||||
response = self.client.get(url, {'part': 25}, format='json')
|
response = self.client.get(url, {'part': 25}, format='json')
|
||||||
self.assertEqual(len(response.data), 2)
|
self.assertEqual(len(response.data), 1)
|
||||||
|
|
||||||
# Filter by an invalid part
|
# Filter by an invalid part
|
||||||
response = self.client.get(url, {'part': 99999}, format='json')
|
response = self.client.get(url, {'part': 99999}, format='json')
|
||||||
@ -252,34 +252,6 @@ class TestBuildViews(TestCase):
|
|||||||
|
|
||||||
self.assertIn(build.title, content)
|
self.assertIn(build.title, content)
|
||||||
|
|
||||||
def test_build_item_create(self):
|
|
||||||
""" Test the BuildItem creation view (ajax form) """
|
|
||||||
|
|
||||||
url = reverse('build-item-create')
|
|
||||||
|
|
||||||
# Try without a part specified
|
|
||||||
response = self.client.get(url, {'build': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Try with an invalid build ID
|
|
||||||
response = self.client.get(url, {'build': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Try with a valid part specified
|
|
||||||
response = self.client.get(url, {'build': 1, 'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Try with an invalid part specified
|
|
||||||
response = self.client.get(url, {'build': 1, 'part': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_build_item_edit(self):
|
|
||||||
""" Test the BuildItem edit view (ajax form) """
|
|
||||||
|
|
||||||
# TODO
|
|
||||||
# url = reverse('build-item-edit')
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_build_output_complete(self):
|
def test_build_output_complete(self):
|
||||||
"""
|
"""
|
||||||
Test the build output completion form
|
Test the build output completion form
|
||||||
|
@ -12,7 +12,6 @@ build_detail_urls = [
|
|||||||
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
||||||
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
|
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
|
||||||
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'),
|
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'),
|
||||||
url(r'^auto-allocate/', views.BuildAutoAllocate.as_view(), name='build-auto-allocate'),
|
|
||||||
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
|
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
|
||||||
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
|
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
|
||||||
|
|
||||||
@ -20,13 +19,6 @@ build_detail_urls = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
build_urls = [
|
build_urls = [
|
||||||
url(r'item/', include([
|
|
||||||
url(r'^(?P<pk>\d+)/', include([
|
|
||||||
url('^edit/', views.BuildItemEdit.as_view(), name='build-item-edit'),
|
|
||||||
url('^delete/', views.BuildItemDelete.as_view(), name='build-item-delete'),
|
|
||||||
])),
|
|
||||||
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
|
|
||||||
])),
|
|
||||||
|
|
||||||
url(r'^(?P<pk>\d+)/', include(build_detail_urls)),
|
url(r'^(?P<pk>\d+)/', include(build_detail_urls)),
|
||||||
|
|
||||||
|
@ -11,13 +11,13 @@ from django.views.generic import DetailView, ListView
|
|||||||
from django.forms import HiddenInput
|
from django.forms import HiddenInput
|
||||||
|
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from .models import Build, BuildItem
|
from .models import Build
|
||||||
from . import forms
|
from . import forms
|
||||||
from stock.models import StockLocation, StockItem
|
from stock.models import StockLocation, StockItem
|
||||||
|
|
||||||
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
|
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
||||||
from InvenTree.views import InvenTreeRoleMixin
|
from InvenTree.views import InvenTreeRoleMixin
|
||||||
from InvenTree.helpers import str2bool, extract_serial_numbers, normalize, isNull
|
from InvenTree.helpers import str2bool, extract_serial_numbers, isNull
|
||||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||||
|
|
||||||
|
|
||||||
@ -77,67 +77,6 @@ class BuildCancel(AjaxUpdateView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class BuildAutoAllocate(AjaxUpdateView):
|
|
||||||
""" View to auto-allocate parts for a build.
|
|
||||||
Follows a simple set of rules to automatically allocate StockItem objects.
|
|
||||||
|
|
||||||
Ref: build.models.Build.getAutoAllocations()
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Build
|
|
||||||
form_class = forms.AutoAllocateForm
|
|
||||||
context_object_name = 'build'
|
|
||||||
ajax_form_title = _('Allocate Stock')
|
|
||||||
ajax_template_name = 'build/auto_allocate.html'
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
"""
|
|
||||||
Initial values for the form.
|
|
||||||
"""
|
|
||||||
|
|
||||||
initials = super().get_initial()
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Get the context data for form rendering.
|
|
||||||
"""
|
|
||||||
|
|
||||||
context = {}
|
|
||||||
|
|
||||||
build = self.get_object()
|
|
||||||
|
|
||||||
context['allocations'] = build.getAutoAllocations()
|
|
||||||
|
|
||||||
context['build'] = build
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
|
|
||||||
form = super().get_form()
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
def validate(self, build, form, **kwargs):
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
def save(self, build, form, **kwargs):
|
|
||||||
"""
|
|
||||||
Once the form has been validated,
|
|
||||||
perform auto-allocations
|
|
||||||
"""
|
|
||||||
|
|
||||||
build.autoAllocate()
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
return {
|
|
||||||
'success': _('Allocated stock to build output'),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BuildOutputCreate(AjaxUpdateView):
|
class BuildOutputCreate(AjaxUpdateView):
|
||||||
"""
|
"""
|
||||||
Create a new build output (StockItem) for a given build.
|
Create a new build output (StockItem) for a given build.
|
||||||
@ -626,268 +565,3 @@ class BuildDelete(AjaxDeleteView):
|
|||||||
model = Build
|
model = Build
|
||||||
ajax_template_name = 'build/delete_build.html'
|
ajax_template_name = 'build/delete_build.html'
|
||||||
ajax_form_title = _('Delete Build Order')
|
ajax_form_title = _('Delete Build Order')
|
||||||
|
|
||||||
|
|
||||||
class BuildItemDelete(AjaxDeleteView):
|
|
||||||
""" View to 'unallocate' a BuildItem.
|
|
||||||
Really we are deleting the BuildItem object from the database.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = BuildItem
|
|
||||||
ajax_template_name = 'build/delete_build_item.html'
|
|
||||||
ajax_form_title = _('Unallocate Stock')
|
|
||||||
context_object_name = 'item'
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
return {
|
|
||||||
'danger': _('Removed parts from build allocation')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BuildItemCreate(AjaxCreateView):
|
|
||||||
"""
|
|
||||||
View for allocating a StockItem to a build output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = BuildItem
|
|
||||||
form_class = forms.EditBuildItemForm
|
|
||||||
ajax_template_name = 'build/create_build_item.html'
|
|
||||||
ajax_form_title = _('Allocate stock to build output')
|
|
||||||
|
|
||||||
# The output StockItem against which the allocation is being made
|
|
||||||
output = None
|
|
||||||
|
|
||||||
# The "part" which is being allocated to the output
|
|
||||||
part = None
|
|
||||||
|
|
||||||
available_stock = None
|
|
||||||
|
|
||||||
def get_context_data(self):
|
|
||||||
"""
|
|
||||||
Provide context data to the template which renders the form.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ctx = super().get_context_data()
|
|
||||||
|
|
||||||
if self.part:
|
|
||||||
ctx['part'] = self.part
|
|
||||||
|
|
||||||
if self.output:
|
|
||||||
ctx['output'] = self.output
|
|
||||||
|
|
||||||
if self.available_stock:
|
|
||||||
ctx['stock'] = self.available_stock
|
|
||||||
else:
|
|
||||||
ctx['no_stock'] = True
|
|
||||||
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
def validate(self, build_item, form, **kwargs):
|
|
||||||
"""
|
|
||||||
Extra validation steps as required
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = form.cleaned_data
|
|
||||||
|
|
||||||
stock_item = data.get('stock_item', None)
|
|
||||||
quantity = data.get('quantity', None)
|
|
||||||
|
|
||||||
if stock_item:
|
|
||||||
# Stock item must actually be in stock!
|
|
||||||
if not stock_item.in_stock:
|
|
||||||
form.add_error('stock_item', _('Item must be currently in stock'))
|
|
||||||
|
|
||||||
# Check that there are enough items available
|
|
||||||
if quantity is not None:
|
|
||||||
available = stock_item.unallocated_quantity()
|
|
||||||
if quantity > available:
|
|
||||||
form.add_error('stock_item', _('Stock item is over-allocated'))
|
|
||||||
form.add_error('quantity', _('Available') + ': ' + str(normalize(available)))
|
|
||||||
else:
|
|
||||||
form.add_error('stock_item', _('Stock item must be selected'))
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
""" Create Form for making / editing new Part object """
|
|
||||||
|
|
||||||
form = super(AjaxCreateView, self).get_form()
|
|
||||||
|
|
||||||
self.build = None
|
|
||||||
self.part = None
|
|
||||||
self.output = None
|
|
||||||
|
|
||||||
# If the Build object is specified, hide the input field.
|
|
||||||
# We do not want the users to be able to move a BuildItem to a different build
|
|
||||||
build_id = form['build'].value()
|
|
||||||
|
|
||||||
if build_id is not None:
|
|
||||||
"""
|
|
||||||
If the build has been provided, hide the widget to change the build selection.
|
|
||||||
Additionally, update the allowable selections for other fields.
|
|
||||||
"""
|
|
||||||
form.fields['build'].widget = HiddenInput()
|
|
||||||
form.fields['install_into'].queryset = StockItem.objects.filter(build=build_id, is_building=True)
|
|
||||||
self.build = Build.objects.get(pk=build_id)
|
|
||||||
else:
|
|
||||||
"""
|
|
||||||
Build has *not* been selected
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
# If the sub_part is supplied, limit to matching stock items
|
|
||||||
part_id = form['part_id'].value()
|
|
||||||
|
|
||||||
if part_id:
|
|
||||||
try:
|
|
||||||
self.part = Part.objects.get(pk=part_id)
|
|
||||||
|
|
||||||
except (ValueError, Part.DoesNotExist):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# If the output stock item is specified, hide the input field
|
|
||||||
output_id = form['install_into'].value()
|
|
||||||
|
|
||||||
if output_id is not None:
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.output = StockItem.objects.get(pk=output_id)
|
|
||||||
form.fields['install_into'].widget = HiddenInput()
|
|
||||||
except (ValueError, StockItem.DoesNotExist):
|
|
||||||
pass
|
|
||||||
|
|
||||||
else:
|
|
||||||
# If the output is not specified, but we know that the part is non-trackable, hide the install_into field
|
|
||||||
if self.part and not self.part.trackable:
|
|
||||||
form.fields['install_into'].widget = HiddenInput()
|
|
||||||
|
|
||||||
if self.build and self.part:
|
|
||||||
available_items = self.build.availableStockItems(self.part, self.output)
|
|
||||||
|
|
||||||
form.fields['stock_item'].queryset = available_items
|
|
||||||
|
|
||||||
self.available_stock = form.fields['stock_item'].queryset.all()
|
|
||||||
|
|
||||||
# If there is only a single stockitem available, select it!
|
|
||||||
if len(self.available_stock) == 1:
|
|
||||||
form.fields['stock_item'].initial = self.available_stock[0].pk
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
""" Provide initial data for BomItem. Look for the folllowing in the GET data:
|
|
||||||
|
|
||||||
- build: pk of the Build object
|
|
||||||
- part: pk of the Part object which we are assigning
|
|
||||||
- output: pk of the StockItem object into which the allocated stock will be installed
|
|
||||||
"""
|
|
||||||
|
|
||||||
initials = super(AjaxCreateView, self).get_initial().copy()
|
|
||||||
|
|
||||||
build_id = self.get_param('build')
|
|
||||||
part_id = self.get_param('part')
|
|
||||||
output_id = self.get_param('install_into')
|
|
||||||
|
|
||||||
# Reference to a Part object
|
|
||||||
part = None
|
|
||||||
|
|
||||||
# Reference to a StockItem object
|
|
||||||
item = None
|
|
||||||
|
|
||||||
# Reference to a Build object
|
|
||||||
build = None
|
|
||||||
|
|
||||||
# Reference to a StockItem object
|
|
||||||
output = None
|
|
||||||
|
|
||||||
if part_id:
|
|
||||||
try:
|
|
||||||
part = Part.objects.get(pk=part_id)
|
|
||||||
initials['part_id'] = part.pk
|
|
||||||
except Part.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if build_id:
|
|
||||||
try:
|
|
||||||
build = Build.objects.get(pk=build_id)
|
|
||||||
initials['build'] = build
|
|
||||||
except Build.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# If the output has been specified
|
|
||||||
if output_id:
|
|
||||||
try:
|
|
||||||
output = StockItem.objects.get(pk=output_id)
|
|
||||||
initials['install_into'] = output
|
|
||||||
except (ValueError, StockItem.DoesNotExist):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Work out how much stock is required
|
|
||||||
if build and part:
|
|
||||||
required_quantity = build.unallocatedQuantity(part, output)
|
|
||||||
else:
|
|
||||||
required_quantity = None
|
|
||||||
|
|
||||||
quantity = self.request.GET.get('quantity', None)
|
|
||||||
|
|
||||||
if quantity is not None:
|
|
||||||
quantity = float(quantity)
|
|
||||||
elif required_quantity is not None:
|
|
||||||
quantity = required_quantity
|
|
||||||
|
|
||||||
item_id = self.get_param('item')
|
|
||||||
|
|
||||||
# If the request specifies a particular StockItem
|
|
||||||
if item_id:
|
|
||||||
try:
|
|
||||||
item = StockItem.objects.get(pk=item_id)
|
|
||||||
except (ValueError, StockItem.DoesNotExist):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# If a StockItem is not selected, try to auto-select one
|
|
||||||
if item is None and part is not None:
|
|
||||||
items = StockItem.objects.filter(part=part)
|
|
||||||
if items.count() == 1:
|
|
||||||
item = items.first()
|
|
||||||
|
|
||||||
# Finally, if a StockItem is selected, ensure the quantity is not too much
|
|
||||||
if item is not None:
|
|
||||||
if quantity is None:
|
|
||||||
quantity = item.unallocated_quantity()
|
|
||||||
else:
|
|
||||||
quantity = min(quantity, item.unallocated_quantity())
|
|
||||||
|
|
||||||
if quantity is not None:
|
|
||||||
initials['quantity'] = quantity
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
|
|
||||||
class BuildItemEdit(AjaxUpdateView):
|
|
||||||
""" View to edit a BuildItem object """
|
|
||||||
|
|
||||||
model = BuildItem
|
|
||||||
ajax_template_name = 'build/edit_build_item.html'
|
|
||||||
form_class = forms.EditBuildItemForm
|
|
||||||
ajax_form_title = _('Edit Stock Allocation')
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
return {
|
|
||||||
'info': _('Updated Build Item'),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
"""
|
|
||||||
Create form for editing a BuildItem.
|
|
||||||
|
|
||||||
- Limit the StockItem options to items that match the part
|
|
||||||
"""
|
|
||||||
|
|
||||||
form = super(BuildItemEdit, self).get_form()
|
|
||||||
|
|
||||||
# Hide fields which we do not wish the user to edit
|
|
||||||
for field in ['build', 'stock_item']:
|
|
||||||
if form[field].value():
|
|
||||||
form.fields[field].widget = HiddenInput()
|
|
||||||
|
|
||||||
form.fields['install_into'].widget = HiddenInput()
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
@ -1,18 +1,6 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from PIL import UnidentifiedImageError
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from InvenTree.ready import canAppAccessDatabase
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("inventree")
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyConfig(AppConfig):
|
class CompanyConfig(AppConfig):
|
||||||
@ -23,29 +11,4 @@ class CompanyConfig(AppConfig):
|
|||||||
This function is called whenever the Company app is loaded.
|
This function is called whenever the Company app is loaded.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if canAppAccessDatabase():
|
pass
|
||||||
self.generate_company_thumbs()
|
|
||||||
|
|
||||||
def generate_company_thumbs(self):
|
|
||||||
|
|
||||||
from .models import Company
|
|
||||||
|
|
||||||
logger.debug("Checking Company image thumbnails")
|
|
||||||
|
|
||||||
try:
|
|
||||||
for company in Company.objects.all():
|
|
||||||
if company.image:
|
|
||||||
url = company.image.thumbnail.name
|
|
||||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
|
||||||
|
|
||||||
if not os.path.exists(loc):
|
|
||||||
logger.info("InvenTree: Generating thumbnail for Company '{c}'".format(c=company.name))
|
|
||||||
try:
|
|
||||||
company.image.render_variations(replace=False)
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.warning(f"Image file '{company.image}' missing")
|
|
||||||
except UnidentifiedImageError:
|
|
||||||
logger.warning(f"Image file '{company.image}' is invalid")
|
|
||||||
except (OperationalError, ProgrammingError):
|
|
||||||
# Getting here probably meant the database was in test mode
|
|
||||||
pass
|
|
||||||
|
@ -7,14 +7,11 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
from django.db import transaction
|
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
||||||
|
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
from rest_framework import filters, status
|
from rest_framework import filters, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import serializers
|
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
|
|
||||||
@ -235,6 +232,7 @@ class POReceive(generics.CreateAPIView):
|
|||||||
|
|
||||||
# Pass the purchase order through to the serializer for validation
|
# Pass the purchase order through to the serializer for validation
|
||||||
context['order'] = self.get_order()
|
context['order'] = self.get_order()
|
||||||
|
context['request'] = self.request
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
@ -252,76 +250,6 @@ class POReceive(generics.CreateAPIView):
|
|||||||
|
|
||||||
return order
|
return order
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
# Which purchase order are we receiving against?
|
|
||||||
self.order = self.get_order()
|
|
||||||
|
|
||||||
# Validate the serialized data
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
# Receive the line items
|
|
||||||
try:
|
|
||||||
self.receive_items(serializer)
|
|
||||||
except DjangoValidationError as exc:
|
|
||||||
# Re-throw a django error as a DRF error
|
|
||||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def receive_items(self, serializer):
|
|
||||||
"""
|
|
||||||
Receive the items
|
|
||||||
|
|
||||||
At this point, much of the heavy lifting has been done for us by DRF serializers!
|
|
||||||
|
|
||||||
We have a list of "items", each a dict which contains:
|
|
||||||
- line_item: A PurchaseOrderLineItem matching this order
|
|
||||||
- location: A destination location
|
|
||||||
- quantity: A validated numerical quantity
|
|
||||||
- status: The status code for the received item
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = serializer.validated_data
|
|
||||||
|
|
||||||
location = data['location']
|
|
||||||
|
|
||||||
items = data['items']
|
|
||||||
|
|
||||||
# Check if the location is not specified for any particular item
|
|
||||||
for item in items:
|
|
||||||
|
|
||||||
line = item['line_item']
|
|
||||||
|
|
||||||
if not item.get('location', None):
|
|
||||||
# If a global location is specified, use that
|
|
||||||
item['location'] = location
|
|
||||||
|
|
||||||
if not item['location']:
|
|
||||||
# The line item specifies a location?
|
|
||||||
item['location'] = line.get_destination()
|
|
||||||
|
|
||||||
if not item['location']:
|
|
||||||
raise ValidationError({
|
|
||||||
'location': _("Destination location must be specified"),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Now we can actually receive the items
|
|
||||||
for item in items:
|
|
||||||
|
|
||||||
self.order.receive_line_item(
|
|
||||||
item['line_item'],
|
|
||||||
item['location'],
|
|
||||||
item['quantity'],
|
|
||||||
self.request.user,
|
|
||||||
status=item['status'],
|
|
||||||
barcode=item.get('barcode', ''),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class POLineItemList(generics.ListCreateAPIView):
|
class POLineItemList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of POLineItem objects
|
""" API endpoint for accessing a list of POLineItem objects
|
||||||
|
@ -7,7 +7,8 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from django.db import models
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
|
from django.db import models, transaction
|
||||||
from django.db.models import Case, When, Value
|
from django.db.models import Case, When, Value
|
||||||
from django.db.models import BooleanField, ExpressionWrapper, F
|
from django.db.models import BooleanField, ExpressionWrapper, F
|
||||||
|
|
||||||
@ -277,35 +278,75 @@ class POReceiveSerializer(serializers.Serializer):
|
|||||||
help_text=_('Select destination location for received items'),
|
help_text=_('Select destination location for received items'),
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_valid(self, raise_exception=False):
|
def validate(self, data):
|
||||||
|
|
||||||
super().is_valid(raise_exception)
|
super().validate(data)
|
||||||
|
|
||||||
# Custom validation
|
|
||||||
data = self.validated_data
|
|
||||||
|
|
||||||
items = data.get('items', [])
|
items = data.get('items', [])
|
||||||
|
|
||||||
if len(items) == 0:
|
if len(items) == 0:
|
||||||
self._errors['items'] = _('Line items must be provided')
|
raise ValidationError({
|
||||||
else:
|
'items': _('Line items must be provided')
|
||||||
# Ensure barcodes are unique
|
})
|
||||||
unique_barcodes = set()
|
|
||||||
|
|
||||||
|
# Ensure barcodes are unique
|
||||||
|
unique_barcodes = set()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
barcode = item.get('barcode', '')
|
||||||
|
|
||||||
|
if barcode:
|
||||||
|
if barcode in unique_barcodes:
|
||||||
|
raise ValidationError(_('Supplied barcode values must be unique'))
|
||||||
|
else:
|
||||||
|
unique_barcodes.add(barcode)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
request = self.context['request']
|
||||||
|
order = self.context['order']
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
location = data.get('location', None)
|
||||||
|
|
||||||
|
# Check if the location is not specified for any particular item
|
||||||
|
for item in items:
|
||||||
|
|
||||||
|
line = item['line_item']
|
||||||
|
|
||||||
|
if not item.get('location', None):
|
||||||
|
# If a global location is specified, use that
|
||||||
|
item['location'] = location
|
||||||
|
|
||||||
|
if not item['location']:
|
||||||
|
# The line item specifies a location?
|
||||||
|
item['location'] = line.get_destination()
|
||||||
|
|
||||||
|
if not item['location']:
|
||||||
|
raise ValidationError({
|
||||||
|
'location': _("Destination location must be specified"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Now we can actually receive the items into stock
|
||||||
|
with transaction.atomic():
|
||||||
for item in items:
|
for item in items:
|
||||||
barcode = item.get('barcode', '')
|
|
||||||
|
|
||||||
if barcode:
|
try:
|
||||||
if barcode in unique_barcodes:
|
order.receive_line_item(
|
||||||
self._errors['items'] = _('Supplied barcode values must be unique')
|
item['line_item'],
|
||||||
break
|
item['location'],
|
||||||
else:
|
item['quantity'],
|
||||||
unique_barcodes.add(barcode)
|
request.user,
|
||||||
|
status=item['status'],
|
||||||
if self._errors and raise_exception:
|
barcode=item.get('barcode', ''),
|
||||||
raise ValidationError(self.errors)
|
)
|
||||||
|
except (ValidationError, DjangoValidationError) as exc:
|
||||||
return not bool(self._errors)
|
# Catch model errors and re-throw as DRF errors
|
||||||
|
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = [
|
fields = [
|
||||||
|
@ -1,13 +1,9 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from PIL import UnidentifiedImageError
|
|
||||||
|
|
||||||
from InvenTree.ready import canAppAccessDatabase
|
from InvenTree.ready import canAppAccessDatabase
|
||||||
|
|
||||||
@ -24,40 +20,8 @@ class PartConfig(AppConfig):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if canAppAccessDatabase():
|
if canAppAccessDatabase():
|
||||||
self.generate_part_thumbnails()
|
|
||||||
self.update_trackable_status()
|
self.update_trackable_status()
|
||||||
|
|
||||||
def generate_part_thumbnails(self):
|
|
||||||
"""
|
|
||||||
Generate thumbnail images for any Part that does not have one.
|
|
||||||
This function exists mainly for legacy support,
|
|
||||||
as any *new* image uploaded will have a thumbnail generated automatically.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .models import Part
|
|
||||||
|
|
||||||
logger.debug("InvenTree: Checking Part image thumbnails")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Only check parts which have images
|
|
||||||
for part in Part.objects.exclude(image=None):
|
|
||||||
if part.image:
|
|
||||||
url = part.image.thumbnail.name
|
|
||||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
|
||||||
|
|
||||||
if not os.path.exists(loc):
|
|
||||||
logger.info("InvenTree: Generating thumbnail for Part '{p}'".format(p=part.name))
|
|
||||||
try:
|
|
||||||
part.image.render_variations(replace=False)
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.warning(f"Image file '{part.image}' missing")
|
|
||||||
pass
|
|
||||||
except UnidentifiedImageError:
|
|
||||||
logger.warning(f"Image file '{part.image}' is invalid")
|
|
||||||
except (OperationalError, ProgrammingError):
|
|
||||||
# Exception if the database has not been migrated yet
|
|
||||||
pass
|
|
||||||
|
|
||||||
def update_trackable_status(self):
|
def update_trackable_status(self):
|
||||||
"""
|
"""
|
||||||
Check for any instances where a trackable part is used in the BOM
|
Check for any instances where a trackable part is used in the BOM
|
||||||
@ -72,7 +36,7 @@ class PartConfig(AppConfig):
|
|||||||
items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True)
|
items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True)
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
print(f"Marking part '{item.part.name}' as trackable")
|
logger.info(f"Marking part '{item.part.name}' as trackable")
|
||||||
item.part.trackable = True
|
item.part.trackable = True
|
||||||
item.part.clean()
|
item.part.clean()
|
||||||
item.part.save()
|
item.part.save()
|
||||||
|
@ -31,3 +31,10 @@
|
|||||||
part: 100
|
part: 100
|
||||||
sub_part: 50
|
sub_part: 50
|
||||||
quantity: 3
|
quantity: 3
|
||||||
|
|
||||||
|
- model: part.bomitem
|
||||||
|
pk: 5
|
||||||
|
fields:
|
||||||
|
part: 1
|
||||||
|
sub_part: 5
|
||||||
|
quantity: 3
|
||||||
|
@ -4,6 +4,7 @@ Part database model definitions
|
|||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
import decimal
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@ -1530,10 +1531,13 @@ class Part(MPTTModel):
|
|||||||
for item in self.get_bom_items().all().select_related('sub_part'):
|
for item in self.get_bom_items().all().select_related('sub_part'):
|
||||||
|
|
||||||
if item.sub_part.pk == self.pk:
|
if item.sub_part.pk == self.pk:
|
||||||
print("Warning: Item contains itself in BOM")
|
logger.warning(f"WARNING: BomItem ID {item.pk} contains itself in BOM")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal, purchase=purchase)
|
q = decimal.Decimal(quantity)
|
||||||
|
i = decimal.Decimal(item.quantity)
|
||||||
|
|
||||||
|
prices = item.sub_part.get_price_range(q * i, internal=internal, purchase=purchase)
|
||||||
|
|
||||||
if prices is None:
|
if prices is None:
|
||||||
continue
|
continue
|
||||||
@ -2329,6 +2333,23 @@ class BomItem(models.Model):
|
|||||||
def get_api_url():
|
def get_api_url():
|
||||||
return reverse('api-bom-list')
|
return reverse('api-bom-list')
|
||||||
|
|
||||||
|
def get_stock_filter(self):
|
||||||
|
"""
|
||||||
|
Return a queryset filter for selecting StockItems which match this BomItem
|
||||||
|
|
||||||
|
- If allow_variants is True, allow all part variants
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Target part
|
||||||
|
part = self.sub_part
|
||||||
|
|
||||||
|
if self.allow_variants:
|
||||||
|
variants = part.get_descendants(include_self=True)
|
||||||
|
return Q(part__in=[v.pk for v in variants])
|
||||||
|
else:
|
||||||
|
return Q(part=part)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
self.clean()
|
self.clean()
|
||||||
|
@ -277,7 +277,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
""" There should be 4 BomItem objects in the database """
|
""" There should be 4 BomItem objects in the database """
|
||||||
url = reverse('api-bom-list')
|
url = reverse('api-bom-list')
|
||||||
response = self.client.get(url, format='json')
|
response = self.client.get(url, format='json')
|
||||||
self.assertEqual(len(response.data), 4)
|
self.assertEqual(len(response.data), 5)
|
||||||
|
|
||||||
def test_get_bom_detail(self):
|
def test_get_bom_detail(self):
|
||||||
# Get the detail for a single BomItem
|
# Get the detail for a single BomItem
|
||||||
|
@ -120,7 +120,13 @@ class BomItemTest(TestCase):
|
|||||||
|
|
||||||
def test_pricing(self):
|
def test_pricing(self):
|
||||||
self.bob.get_price(1)
|
self.bob.get_price(1)
|
||||||
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(84.5), Decimal(89.5)))
|
self.assertEqual(
|
||||||
|
self.bob.get_bom_price_range(1, internal=True),
|
||||||
|
(Decimal(29.5), Decimal(89.5))
|
||||||
|
)
|
||||||
# remove internal price for R_2K2_0805
|
# remove internal price for R_2K2_0805
|
||||||
self.r1.internal_price_breaks.delete()
|
self.r1.internal_price_breaks.delete()
|
||||||
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(82.5), Decimal(87.5)))
|
self.assertEqual(
|
||||||
|
self.bob.get_bom_price_range(1, internal=True),
|
||||||
|
(Decimal(27.5), Decimal(87.5))
|
||||||
|
)
|
||||||
|
@ -2,11 +2,18 @@
|
|||||||
JSON API for the Stock app
|
JSON API for the Stock app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import ugettext_lazy as _
|
|
||||||
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
@ -22,7 +29,7 @@ from .models import StockItemTracking
|
|||||||
from .models import StockItemAttachment
|
from .models import StockItemAttachment
|
||||||
from .models import StockItemTestResult
|
from .models import StockItemTestResult
|
||||||
|
|
||||||
from part.models import Part, PartCategory
|
from part.models import BomItem, Part, PartCategory
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
@ -45,10 +52,6 @@ from InvenTree.helpers import str2bool, isNull
|
|||||||
from InvenTree.api import AttachmentMixin
|
from InvenTree.api import AttachmentMixin
|
||||||
from InvenTree.filters import InvenTreeOrderingFilter
|
from InvenTree.filters import InvenTreeOrderingFilter
|
||||||
|
|
||||||
from decimal import Decimal, InvalidOperation
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
|
|
||||||
class StockCategoryTree(TreeSerializer):
|
class StockCategoryTree(TreeSerializer):
|
||||||
title = _('Stock')
|
title = _('Stock')
|
||||||
@ -670,14 +673,14 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
|
"""
|
||||||
|
Custom filtering for the StockItem queryset
|
||||||
|
"""
|
||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
# Perform basic filtering:
|
|
||||||
# Note: We do not let DRF filter here, it be slow AF
|
|
||||||
|
|
||||||
supplier_part = params.get('supplier_part', None)
|
supplier_part = params.get('supplier_part', None)
|
||||||
|
|
||||||
if supplier_part:
|
if supplier_part:
|
||||||
@ -818,7 +821,7 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
if loc_id is not None:
|
if loc_id is not None:
|
||||||
|
|
||||||
# Filter by 'null' location (i.e. top-level items)
|
# Filter by 'null' location (i.e. top-level items)
|
||||||
if isNull(loc_id):
|
if isNull(loc_id) and not cascade:
|
||||||
queryset = queryset.filter(location=None)
|
queryset = queryset.filter(location=None)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@ -843,6 +846,18 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
except (ValueError, PartCategory.DoesNotExist):
|
except (ValueError, PartCategory.DoesNotExist):
|
||||||
raise ValidationError({"category": "Invalid category id specified"})
|
raise ValidationError({"category": "Invalid category id specified"})
|
||||||
|
|
||||||
|
# Does the client wish to filter by BomItem
|
||||||
|
bom_item_id = params.get('bom_item', None)
|
||||||
|
|
||||||
|
if bom_item_id is not None:
|
||||||
|
try:
|
||||||
|
bom_item = BomItem.objects.get(pk=bom_item_id)
|
||||||
|
|
||||||
|
queryset = queryset.filter(bom_item.get_stock_filter())
|
||||||
|
|
||||||
|
except (ValueError, BomItem.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
# Filter by StockItem status
|
# Filter by StockItem status
|
||||||
status = params.get('status', None)
|
status = params.get('status', None)
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@
|
|||||||
/* globals
|
/* globals
|
||||||
buildStatusDisplay,
|
buildStatusDisplay,
|
||||||
constructForm,
|
constructForm,
|
||||||
getFieldByName,
|
|
||||||
global_settings,
|
global_settings,
|
||||||
imageHoverIcon,
|
imageHoverIcon,
|
||||||
inventreeGet,
|
inventreeGet,
|
||||||
@ -20,6 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
|
allocateStockToBuild,
|
||||||
editBuildOrder,
|
editBuildOrder,
|
||||||
loadAllocationTable,
|
loadAllocationTable,
|
||||||
loadBuildOrderAllocationTable,
|
loadBuildOrderAllocationTable,
|
||||||
@ -102,6 +102,7 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
var buildId = buildInfo.pk;
|
var buildId = buildInfo.pk;
|
||||||
|
var partId = buildInfo.part;
|
||||||
|
|
||||||
var outputId = 'untracked';
|
var outputId = 'untracked';
|
||||||
|
|
||||||
@ -120,11 +121,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
|||||||
|
|
||||||
var html = `<div class='btn-group float-right' role='group'>`;
|
var html = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
// "Auto" allocation only works for untracked stock items
|
if (lines > 0) {
|
||||||
if (!output && lines > 0) {
|
|
||||||
html += makeIconButton(
|
html += makeIconButton(
|
||||||
'fa-magic icon-blue', 'button-output-auto', outputId,
|
'fa-sign-in-alt icon-blue', 'button-output-auto', outputId,
|
||||||
'{% trans "Auto-allocate stock items to this output" %}',
|
'{% trans "Allocate stock items to this build output" %}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,7 +136,6 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (output) {
|
if (output) {
|
||||||
|
|
||||||
// Add a button to "complete" the particular build output
|
// Add a button to "complete" the particular build output
|
||||||
@ -163,11 +162,17 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
|
|||||||
|
|
||||||
// Add callbacks for the buttons
|
// Add callbacks for the buttons
|
||||||
$(panel).find(`#button-output-auto-${outputId}`).click(function() {
|
$(panel).find(`#button-output-auto-${outputId}`).click(function() {
|
||||||
|
|
||||||
|
var bom_items = $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('getData');
|
||||||
|
|
||||||
// Launch modal dialog to perform auto-allocation
|
// Launch modal dialog to perform auto-allocation
|
||||||
launchModalForm(`/build/${buildId}/auto-allocate/`,
|
allocateStockToBuild(
|
||||||
|
buildId,
|
||||||
|
partId,
|
||||||
|
bom_items,
|
||||||
{
|
{
|
||||||
data: {
|
source_location: buildInfo.source_location,
|
||||||
},
|
output: outputId,
|
||||||
success: reloadTable,
|
success: reloadTable,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -344,18 +349,26 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
function requiredQuantity(row) {
|
function requiredQuantity(row) {
|
||||||
// Return the requied quantity for a given row
|
// Return the requied quantity for a given row
|
||||||
|
|
||||||
|
var quantity = 0;
|
||||||
|
|
||||||
if (output) {
|
if (output) {
|
||||||
// "Tracked" parts are calculated against individual build outputs
|
// "Tracked" parts are calculated against individual build outputs
|
||||||
return row.quantity * output.quantity;
|
quantity = row.quantity * output.quantity;
|
||||||
} else {
|
} else {
|
||||||
// "Untracked" parts are specified against the build itself
|
// "Untracked" parts are specified against the build itself
|
||||||
return row.quantity * buildInfo.quantity;
|
quantity = row.quantity * buildInfo.quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the required quantity in the row data
|
||||||
|
row.required = quantity;
|
||||||
|
|
||||||
|
return quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sumAllocations(row) {
|
function sumAllocations(row) {
|
||||||
// Calculat total allocations for a given row
|
// Calculat total allocations for a given row
|
||||||
if (!row.allocations) {
|
if (!row.allocations) {
|
||||||
|
row.allocated = 0;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,6 +378,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
quantity += item.quantity;
|
quantity += item.quantity;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
row.allocated = quantity;
|
||||||
|
|
||||||
return quantity;
|
return quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,52 +392,28 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
// Primary key of the 'sub_part'
|
// Primary key of the 'sub_part'
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
// Launch form to allocate new stock against this output
|
// Extract BomItem information from this row
|
||||||
launchModalForm('{% url "build-item-create" %}', {
|
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
|
||||||
success: reloadTable,
|
|
||||||
data: {
|
if (!row) {
|
||||||
part: pk,
|
console.log('WARNING: getRowByUniqueId returned null');
|
||||||
build: buildId,
|
return;
|
||||||
install_into: outputId,
|
}
|
||||||
},
|
|
||||||
secondary: [
|
allocateStockToBuild(
|
||||||
{
|
buildId,
|
||||||
field: 'stock_item',
|
partId,
|
||||||
label: '{% trans "New Stock Item" %}',
|
[
|
||||||
title: '{% trans "Create new Stock Item" %}',
|
row,
|
||||||
url: '{% url "stock-item-create" %}',
|
|
||||||
data: {
|
|
||||||
part: pk,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
callback: [
|
{
|
||||||
{
|
source_location: buildInfo.source_location,
|
||||||
field: 'stock_item',
|
success: function(data) {
|
||||||
action: function(value) {
|
$(table).bootstrapTable('refresh');
|
||||||
inventreeGet(
|
},
|
||||||
`/api/stock/${value}/`, {},
|
output: output == null ? null : output.pk,
|
||||||
{
|
}
|
||||||
success: function(response) {
|
);
|
||||||
|
|
||||||
// How many items are actually available for the given stock item?
|
|
||||||
var available = response.quantity - response.allocated;
|
|
||||||
|
|
||||||
var field = getFieldByName('#modal-form', 'quantity');
|
|
||||||
|
|
||||||
// Allocation quantity initial value
|
|
||||||
var initial = field.attr('value');
|
|
||||||
|
|
||||||
if (available < initial) {
|
|
||||||
field.val(available);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Callback for 'buy' button
|
// Callback for 'buy' button
|
||||||
@ -636,11 +627,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
text = `{% trans "Quantity" %}: ${row.quantity}`;
|
text = `{% trans "Quantity" %}: ${row.quantity}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
{% if build.status == BuildStatus.COMPLETE %}
|
var pk = row.stock_item || row.pk;
|
||||||
url = `/stock/item/${row.pk}/`;
|
|
||||||
{% else %}
|
url = `/stock/item/${pk}/`;
|
||||||
url = `/stock/item/${row.stock_item}/`;
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
return renderLink(text, url);
|
return renderLink(text, url);
|
||||||
}
|
}
|
||||||
@ -687,22 +676,31 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
// Assign button callbacks to the newly created allocation buttons
|
// Assign button callbacks to the newly created allocation buttons
|
||||||
subTable.find('.button-allocation-edit').click(function() {
|
subTable.find('.button-allocation-edit').click(function() {
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
launchModalForm(`/build/item/${pk}/edit/`, {
|
|
||||||
success: reloadTable,
|
constructForm(`/api/build/item/${pk}/`, {
|
||||||
|
fields: {
|
||||||
|
quantity: {},
|
||||||
|
},
|
||||||
|
title: '{% trans "Edit Allocation" %}',
|
||||||
|
onSuccess: reloadTable,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
subTable.find('.button-allocation-delete').click(function() {
|
subTable.find('.button-allocation-delete').click(function() {
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
launchModalForm(`/build/item/${pk}/delete/`, {
|
|
||||||
success: reloadTable,
|
constructForm(`/api/build/item/${pk}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
title: '{% trans "Remove Allocation" %}',
|
||||||
|
onSuccess: reloadTable,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
field: 'pk',
|
visible: true,
|
||||||
visible: false,
|
switchable: false,
|
||||||
|
checkbox: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'sub_part_detail.full_name',
|
field: 'sub_part_detail.full_name',
|
||||||
@ -824,6 +822,317 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocate stock items to a build
|
||||||
|
*
|
||||||
|
* arguments:
|
||||||
|
* - buildId: ID / PK value for the build
|
||||||
|
* - partId: ID / PK value for the part being built
|
||||||
|
* - bom_items: A list of BomItem objects to be allocated
|
||||||
|
*
|
||||||
|
* options:
|
||||||
|
* - output: ID / PK of the associated build output (or null for untracked items)
|
||||||
|
* - source_location: ID / PK of the top-level StockLocation to take parts from (or null)
|
||||||
|
*/
|
||||||
|
function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||||
|
|
||||||
|
// ID of the associated "build output" (or null)
|
||||||
|
var output_id = options.output || null;
|
||||||
|
|
||||||
|
var source_location = options.source_location;
|
||||||
|
|
||||||
|
function renderBomItemRow(bom_item, quantity) {
|
||||||
|
|
||||||
|
var pk = bom_item.pk;
|
||||||
|
var sub_part = bom_item.sub_part_detail;
|
||||||
|
|
||||||
|
var thumb = thumbnailImage(bom_item.sub_part_detail.thumbnail);
|
||||||
|
|
||||||
|
var delete_button = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
|
delete_button += makeIconButton(
|
||||||
|
'fa-times icon-red',
|
||||||
|
'button-row-remove',
|
||||||
|
pk,
|
||||||
|
'{% trans "Remove row" %}',
|
||||||
|
);
|
||||||
|
|
||||||
|
delete_button += `</div>`;
|
||||||
|
|
||||||
|
var quantity_input = constructField(
|
||||||
|
`items_quantity_${pk}`,
|
||||||
|
{
|
||||||
|
type: 'decimal',
|
||||||
|
min_value: 0,
|
||||||
|
value: quantity || 0,
|
||||||
|
title: '{% trans "Specify stock allocation quantity" %}',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hideLabels: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
var allocated_display = makeProgressBar(
|
||||||
|
bom_item.allocated,
|
||||||
|
bom_item.required,
|
||||||
|
);
|
||||||
|
|
||||||
|
var stock_input = constructField(
|
||||||
|
`items_stock_item_${pk}`,
|
||||||
|
{
|
||||||
|
type: 'related field',
|
||||||
|
required: 'true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hideLabels: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// var stock_input = constructRelatedFieldInput(`items_stock_item_${pk}`);
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<tr id='allocation_row_${pk}' class='part-allocation-row'>
|
||||||
|
<td id='part_${pk}'>
|
||||||
|
${thumb} ${sub_part.full_name}
|
||||||
|
</td>
|
||||||
|
<td id='allocated_${pk}'>
|
||||||
|
${allocated_display}
|
||||||
|
</td>
|
||||||
|
<td id='stock_item_${pk}'>
|
||||||
|
${stock_input}
|
||||||
|
</td>
|
||||||
|
<td id='quantity_${pk}'>
|
||||||
|
${quantity_input}
|
||||||
|
</td>
|
||||||
|
<td id='buttons_${pk}'>
|
||||||
|
${delete_button}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
var table_entries = '';
|
||||||
|
|
||||||
|
for (var idx = 0; idx < bom_items.length; idx++) {
|
||||||
|
var bom_item = bom_items[idx];
|
||||||
|
|
||||||
|
var required = bom_item.required || 0;
|
||||||
|
var allocated = bom_item.allocated || 0;
|
||||||
|
var remaining = required - allocated;
|
||||||
|
|
||||||
|
if (remaining < 0) {
|
||||||
|
remaining = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table_entries += renderBomItemRow(bom_item, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bom_items.length == 0) {
|
||||||
|
|
||||||
|
showAlertDialog(
|
||||||
|
'{% trans "Select Parts" %}',
|
||||||
|
'{% trans "You must select at least one part to allocate" %}',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = ``;
|
||||||
|
|
||||||
|
// Render a "take from" input
|
||||||
|
html += constructField(
|
||||||
|
'take_from',
|
||||||
|
{
|
||||||
|
type: 'related field',
|
||||||
|
label: '{% trans "Source Location" %}',
|
||||||
|
help_text: '{% trans "Select source location (leave blank to take from all locations)" %}',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create table of parts
|
||||||
|
html += `
|
||||||
|
<table class='table table-striped table-condensed' id='stock-allocation-table'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Part" %}</th>
|
||||||
|
<th>{% trans "Allocated" %}</th>
|
||||||
|
<th style='min-width: 250px;'>{% trans "Stock Item" %}</th>
|
||||||
|
<th>{% trans "Quantity" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${table_entries}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
constructForm(`/api/build/${build_id}/allocate/`, {
|
||||||
|
method: 'POST',
|
||||||
|
fields: {},
|
||||||
|
preFormContent: html,
|
||||||
|
confirm: true,
|
||||||
|
confirmMessage: '{% trans "Confirm stock allocation" %}',
|
||||||
|
title: '{% trans "Allocate Stock Items to Build Order" %}',
|
||||||
|
afterRender: function(fields, options) {
|
||||||
|
|
||||||
|
var take_from_field = {
|
||||||
|
name: 'take_from',
|
||||||
|
model: 'stocklocation',
|
||||||
|
api_url: '{% url "api-location-list" %}',
|
||||||
|
required: false,
|
||||||
|
type: 'related field',
|
||||||
|
value: source_location,
|
||||||
|
noResults: function(query) {
|
||||||
|
return '{% trans "No matching stock locations" %}';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize "take from" field
|
||||||
|
initializeRelatedField(
|
||||||
|
take_from_field,
|
||||||
|
null,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize stock item fields
|
||||||
|
bom_items.forEach(function(bom_item) {
|
||||||
|
initializeRelatedField(
|
||||||
|
{
|
||||||
|
name: `items_stock_item_${bom_item.pk}`,
|
||||||
|
api_url: '{% url "api-stock-list" %}',
|
||||||
|
filters: {
|
||||||
|
bom_item: bom_item.pk,
|
||||||
|
in_stock: true,
|
||||||
|
part_detail: false,
|
||||||
|
location_detail: true,
|
||||||
|
},
|
||||||
|
model: 'stockitem',
|
||||||
|
required: true,
|
||||||
|
render_part_detail: false,
|
||||||
|
render_location_detail: true,
|
||||||
|
auto_fill: true,
|
||||||
|
adjustFilters: function(filters) {
|
||||||
|
// Restrict query to the selected location
|
||||||
|
var location = getFormFieldValue(
|
||||||
|
'take_from',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
modal: options.modal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
filters.location = location;
|
||||||
|
filters.cascade = true;
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
},
|
||||||
|
noResults: function(query) {
|
||||||
|
return '{% trans "No matching stock items" %}';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add callback to "clear" button for take_from field
|
||||||
|
addClearCallback(
|
||||||
|
'take_from',
|
||||||
|
take_from_field,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add button callbacks
|
||||||
|
$(options.modal).find('.button-row-remove').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
$(options.modal).find(`#allocation_row_${pk}`).remove();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmit: function(fields, opts) {
|
||||||
|
|
||||||
|
// Extract elements from the form
|
||||||
|
var data = {
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
|
||||||
|
var item_pk_values = [];
|
||||||
|
|
||||||
|
bom_items.forEach(function(item) {
|
||||||
|
|
||||||
|
var quantity = getFormFieldValue(
|
||||||
|
`items_quantity_${item.pk}`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
modal: opts.modal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
var stock_item = getFormFieldValue(
|
||||||
|
`items_stock_item_${item.pk}`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
modal: opts.modal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (quantity != null) {
|
||||||
|
data.items.push({
|
||||||
|
bom_item: item.pk,
|
||||||
|
stock_item: stock_item,
|
||||||
|
quantity: quantity,
|
||||||
|
output: output_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
item_pk_values.push(item.pk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide nested values
|
||||||
|
opts.nested = {
|
||||||
|
'items': item_pk_values
|
||||||
|
};
|
||||||
|
|
||||||
|
inventreePut(
|
||||||
|
opts.url,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
success: function(response) {
|
||||||
|
// Hide the modal
|
||||||
|
$(opts.modal).modal('hide');
|
||||||
|
|
||||||
|
if (options.success) {
|
||||||
|
options.success(response);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
switch (xhr.status) {
|
||||||
|
case 400:
|
||||||
|
handleFormErrors(xhr.responseJSON, fields, opts);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$(opts.modal).modal('hide');
|
||||||
|
showApiError(xhr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function loadBuildTable(table, options) {
|
function loadBuildTable(table, options) {
|
||||||
// Display a table of Build objects
|
// Display a table of Build objects
|
||||||
|
|
||||||
|
@ -728,10 +728,17 @@ function updateFieldValues(fields, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Update the value of a named field
|
||||||
|
*/
|
||||||
function updateFieldValue(name, value, field, options) {
|
function updateFieldValue(name, value, field, options) {
|
||||||
var el = $(options.modal).find(`#id_${name}`);
|
var el = $(options.modal).find(`#id_${name}`);
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
console.log(`WARNING: updateFieldValue could not find field '${name}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
el.prop('checked', value);
|
el.prop('checked', value);
|
||||||
@ -864,6 +871,78 @@ function clearFormErrors(options) {
|
|||||||
$(options.modal).find('#non-field-errors').html('');
|
$(options.modal).find('#non-field-errors').html('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Display form error messages as returned from the server,
|
||||||
|
* specifically for errors returned in an array.
|
||||||
|
*
|
||||||
|
* We need to know the unique ID of each item in the array,
|
||||||
|
* and the array length must equal the length of the array returned from the server
|
||||||
|
*
|
||||||
|
* arguments:
|
||||||
|
* - response: The JSON error response from the server
|
||||||
|
* - parent: The name of the parent field e.g. "items"
|
||||||
|
* - options: The global options struct
|
||||||
|
*
|
||||||
|
* options:
|
||||||
|
* - nested: A map of nested ID values for the "parent" field
|
||||||
|
* e.g.
|
||||||
|
* {
|
||||||
|
* "items": [
|
||||||
|
* 1,
|
||||||
|
* 2,
|
||||||
|
* 12
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
function handleNestedErrors(errors, field_name, options) {
|
||||||
|
|
||||||
|
var error_list = errors[field_name];
|
||||||
|
|
||||||
|
// Ignore null or empty list
|
||||||
|
if (!error_list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nest_list = nest_list = options['nested'][field_name];
|
||||||
|
|
||||||
|
// Nest list must be provided!
|
||||||
|
if (!nest_list) {
|
||||||
|
console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var idx = 0; idx < error_list.length; idx++) {
|
||||||
|
|
||||||
|
var error_item = error_list[idx];
|
||||||
|
|
||||||
|
if (idx >= nest_list.length) {
|
||||||
|
console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the particular ID of the nested item
|
||||||
|
var nest_id = nest_list[idx];
|
||||||
|
|
||||||
|
// Here, error_item is a map of field names to error messages
|
||||||
|
for (sub_field_name in error_item) {
|
||||||
|
var errors = error_item[sub_field_name];
|
||||||
|
|
||||||
|
// Find the target (nested) field
|
||||||
|
var target = `${field_name}_${sub_field_name}_${nest_id}`;
|
||||||
|
|
||||||
|
for (var ii = errors.length-1; ii >= 0; ii--) {
|
||||||
|
|
||||||
|
var error_text = errors[ii];
|
||||||
|
|
||||||
|
addFieldErrorMessage(target, error_text, ii, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Display form error messages as returned from the server.
|
* Display form error messages as returned from the server.
|
||||||
@ -913,28 +992,30 @@ function handleFormErrors(errors, fields, options) {
|
|||||||
|
|
||||||
for (var field_name in errors) {
|
for (var field_name in errors) {
|
||||||
|
|
||||||
// Add the 'has-error' class
|
if (field_name in fields) {
|
||||||
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
|
|
||||||
|
|
||||||
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
|
var field = fields[field_name];
|
||||||
|
|
||||||
var field_errors = errors[field_name];
|
if ((field.type == 'field') && ('child' in field)) {
|
||||||
|
// This is a "nested" field
|
||||||
|
handleNestedErrors(errors, field_name, options);
|
||||||
|
} else {
|
||||||
|
// This is a "simple" field
|
||||||
|
|
||||||
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
|
var field_errors = errors[field_name];
|
||||||
first_error_field = field_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add an entry for each returned error message
|
if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
|
||||||
for (var ii = field_errors.length-1; ii >= 0; ii--) {
|
first_error_field = field_name;
|
||||||
|
}
|
||||||
|
|
||||||
var error_text = field_errors[ii];
|
// Add an entry for each returned error message
|
||||||
|
for (var ii = field_errors.length-1; ii >= 0; ii--) {
|
||||||
|
|
||||||
var error_html = `
|
var error_text = field_errors[ii];
|
||||||
<span id='error_${ii+1}_id_${field_name}' class='help-block form-error-message'>
|
|
||||||
<strong>${error_text}</strong>
|
|
||||||
</span>`;
|
|
||||||
|
|
||||||
field_dom.append(error_html);
|
addFieldErrorMessage(field_name, error_text, ii, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -952,6 +1033,30 @@ function handleFormErrors(errors, fields, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add a rendered error message to the provided field
|
||||||
|
*/
|
||||||
|
function addFieldErrorMessage(field_name, error_text, error_idx, options) {
|
||||||
|
|
||||||
|
// Add the 'has-error' class
|
||||||
|
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
|
||||||
|
|
||||||
|
var field_dom = $(options.modal).find(`#errors-${field_name}`);
|
||||||
|
|
||||||
|
if (field_dom) {
|
||||||
|
|
||||||
|
var error_html = `
|
||||||
|
<span id='error_${error_idx}_id_${field_name}' class='help-block form-error-message'>
|
||||||
|
<strong>${error_text}</strong>
|
||||||
|
</span>`;
|
||||||
|
|
||||||
|
field_dom.append(error_html);
|
||||||
|
} else {
|
||||||
|
console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function isFieldVisible(field, options) {
|
function isFieldVisible(field, options) {
|
||||||
|
|
||||||
return $(options.modal).find(`#div_id_${field}`).is(':visible');
|
return $(options.modal).find(`#div_id_${field}`).is(':visible');
|
||||||
@ -1007,7 +1112,14 @@ function addClearCallbacks(fields, options) {
|
|||||||
|
|
||||||
function addClearCallback(name, field, options) {
|
function addClearCallback(name, field, options) {
|
||||||
|
|
||||||
$(options.modal).find(`#clear_${name}`).click(function() {
|
var el = $(options.modal).find(`#clear_${name}`);
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
console.log(`WARNING: addClearCallback could not find field '${name}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.click(function() {
|
||||||
updateFieldValue(name, null, field, options);
|
updateFieldValue(name, null, field, options);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1168,7 +1280,7 @@ function addSecondaryModal(field, fields, options) {
|
|||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Initializea single related-field
|
* Initialize a single related-field
|
||||||
*
|
*
|
||||||
* argument:
|
* argument:
|
||||||
* - modal: DOM identifier for the modal window
|
* - modal: DOM identifier for the modal window
|
||||||
@ -1182,7 +1294,7 @@ function initializeRelatedField(field, fields, options) {
|
|||||||
|
|
||||||
if (!field.api_url) {
|
if (!field.api_url) {
|
||||||
// TODO: Provide manual api_url option?
|
// TODO: Provide manual api_url option?
|
||||||
console.log(`Related field '${name}' missing 'api_url' parameter.`);
|
console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1203,6 +1315,15 @@ function initializeRelatedField(field, fields, options) {
|
|||||||
placeholder: '',
|
placeholder: '',
|
||||||
dropdownParent: $(options.modal),
|
dropdownParent: $(options.modal),
|
||||||
dropdownAutoWidth: false,
|
dropdownAutoWidth: false,
|
||||||
|
language: {
|
||||||
|
noResults: function(query) {
|
||||||
|
if (field.noResults) {
|
||||||
|
return field.noResults(query);
|
||||||
|
} else {
|
||||||
|
return '{% trans "No results found" %}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
ajax: {
|
ajax: {
|
||||||
url: field.api_url,
|
url: field.api_url,
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
@ -1226,6 +1347,11 @@ function initializeRelatedField(field, fields, options) {
|
|||||||
query.offset = offset;
|
query.offset = offset;
|
||||||
query.limit = pageSize;
|
query.limit = pageSize;
|
||||||
|
|
||||||
|
// Allow custom run-time filter augmentation
|
||||||
|
if ('adjustFilters' in field) {
|
||||||
|
query = field.adjustFilters(query);
|
||||||
|
}
|
||||||
|
|
||||||
return query;
|
return query;
|
||||||
},
|
},
|
||||||
processResults: function(response) {
|
processResults: function(response) {
|
||||||
@ -1319,6 +1445,7 @@ function initializeRelatedField(field, fields, options) {
|
|||||||
|
|
||||||
// If a 'value' is already defined, grab the model info from the server
|
// If a 'value' is already defined, grab the model info from the server
|
||||||
if (field.value) {
|
if (field.value) {
|
||||||
|
|
||||||
var pk = field.value;
|
var pk = field.value;
|
||||||
var url = `${field.api_url}/${pk}/`.replace('//', '/');
|
var url = `${field.api_url}/${pk}/`.replace('//', '/');
|
||||||
|
|
||||||
@ -1327,6 +1454,24 @@ function initializeRelatedField(field, fields, options) {
|
|||||||
setRelatedFieldData(name, data, options);
|
setRelatedFieldData(name, data, options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else if (field.auto_fill) {
|
||||||
|
// Attempt to auto-fill the field
|
||||||
|
|
||||||
|
var filters = field.filters || {};
|
||||||
|
|
||||||
|
// Enforce pagination, limit to a single return (for fast query)
|
||||||
|
filters.limit = 1;
|
||||||
|
filters.offset = 0;
|
||||||
|
|
||||||
|
inventreeGet(field.api_url, field.filters || {}, {
|
||||||
|
success: function(data) {
|
||||||
|
|
||||||
|
// Only a single result is available, given the provided filters
|
||||||
|
if (data.count == 1) {
|
||||||
|
setRelatedFieldData(name, data.results[0], options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1884,7 +2029,7 @@ function constructChoiceInput(name, parameters) {
|
|||||||
*/
|
*/
|
||||||
function constructRelatedFieldInput(name) {
|
function constructRelatedFieldInput(name) {
|
||||||
|
|
||||||
var html = `<select id='id_${name}' class='select form-control' name='${name}'></select>`;
|
var html = `<select id='id_${name}' class='select form-control' name='${name}' style='width: 100%;'></select>`;
|
||||||
|
|
||||||
// Don't load any options - they will be filled via an AJAX request
|
// Don't load any options - they will be filled via an AJAX request
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ function imageHoverIcon(url) {
|
|||||||
function thumbnailImage(url) {
|
function thumbnailImage(url) {
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
url = '/static/img/blank_img.png';
|
url = blankImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Support insertion of custom classes
|
// TODO: Support insertion of custom classes
|
||||||
|
@ -37,7 +37,7 @@ function renderCompany(name, data, parameters, options) {
|
|||||||
|
|
||||||
html += `<span><b>${data.name}</b></span> - <i>${data.description}</i>`;
|
html += `<span><b>${data.name}</b></span> - <i>${data.description}</i>`;
|
||||||
|
|
||||||
html += `<span class='float-right'>{% trans "Company ID" %}: ${data.pk}</span>`;
|
html += `<span class='float-right'><small>{% trans "Company ID" %}: ${data.pk}</small></span>`;
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@ -47,22 +47,59 @@ function renderCompany(name, data, parameters, options) {
|
|||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
function renderStockItem(name, data, parameters, options) {
|
function renderStockItem(name, data, parameters, options) {
|
||||||
|
|
||||||
var image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
|
var image = blankImage();
|
||||||
|
|
||||||
var html = `<img src='${image}' class='select2-thumbnail'>`;
|
if (data.part_detail) {
|
||||||
|
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
|
||||||
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
|
|
||||||
|
|
||||||
if (data.serial && data.quantity == 1) {
|
|
||||||
html += ` - <i>{% trans "Serial Number" %}: ${data.serial}`;
|
|
||||||
} else {
|
|
||||||
html += ` - <i>{% trans "Quantity" %}: ${data.quantity}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.part_detail.description) {
|
var html = '';
|
||||||
|
|
||||||
|
var render_part_detail = true;
|
||||||
|
|
||||||
|
if ('render_part_detail' in parameters) {
|
||||||
|
render_part_detail = parameters['render_part_detail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (render_part_detail) {
|
||||||
|
html += `<img src='${image}' class='select2-thumbnail'>`;
|
||||||
|
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<span>';
|
||||||
|
|
||||||
|
if (data.serial && data.quantity == 1) {
|
||||||
|
html += `{% trans "Serial Number" %}: ${data.serial}`;
|
||||||
|
} else {
|
||||||
|
html += `{% trans "Quantity" %}: ${data.quantity}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</span>';
|
||||||
|
|
||||||
|
if (render_part_detail && data.part_detail.description) {
|
||||||
html += `<p><small>${data.part_detail.description}</small></p>`;
|
html += `<p><small>${data.part_detail.description}</small></p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var render_stock_id = true;
|
||||||
|
|
||||||
|
if ('render_stock_id' in parameters) {
|
||||||
|
render_stock_id = parameters['render_stock_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (render_stock_id) {
|
||||||
|
html += `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
var render_location_detail = false;
|
||||||
|
|
||||||
|
if ('render_location_detail' in parameters) {
|
||||||
|
render_location_detail = parameters['render_location_detail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (render_location_detail && data.location_detail) {
|
||||||
|
html += `<span> - ${data.location_detail.name}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,7 +116,7 @@ function renderStockLocation(name, data, parameters, options) {
|
|||||||
html += ` - <i>${data.description}</i>`;
|
html += ` - <i>${data.description}</i>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `<span class='float-right'>{% trans "Location ID" %}: ${data.pk}</span>`;
|
html += `<span class='float-right'><small>{% trans "Location ID" %}: ${data.pk}</small></span>`;
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@ -96,7 +133,7 @@ function renderBuild(name, data, parameters, options) {
|
|||||||
var html = select2Thumbnail(image);
|
var html = select2Thumbnail(image);
|
||||||
|
|
||||||
html += `<span><b>${data.reference}</b></span> - ${data.quantity} x ${data.part_detail.full_name}`;
|
html += `<span><b>${data.reference}</b></span> - ${data.quantity} x ${data.part_detail.full_name}`;
|
||||||
html += `<span class='float-right'>{% trans "Build ID" %}: ${data.pk}</span>`;
|
html += `<span class='float-right'><small>{% trans "Build ID" %}: ${data.pk}</span></span>`;
|
||||||
|
|
||||||
html += `<p><i>${data.title}</i></p>`;
|
html += `<p><i>${data.title}</i></p>`;
|
||||||
|
|
||||||
@ -116,7 +153,7 @@ function renderPart(name, data, parameters, options) {
|
|||||||
html += ` - <i>${data.description}</i>`;
|
html += ` - <i>${data.description}</i>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `<span class='float-right'>{% trans "Part ID" %}: ${data.pk}</span>`;
|
html += `<span class='float-right'><small>{% trans "Part ID" %}: ${data.pk}</small></span>`;
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@ -168,7 +205,7 @@ function renderPartCategory(name, data, parameters, options) {
|
|||||||
html += ` - <i>${data.description}</i>`;
|
html += ` - <i>${data.description}</i>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `<span class='float-right'>{% trans "Category ID" %}: ${data.pk}</span>`;
|
html += `<span class='float-right'><small>{% trans "Category ID" %}: ${data.pk}</small></span>`;
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@ -205,7 +242,7 @@ function renderManufacturerPart(name, data, parameters, options) {
|
|||||||
html += ` <span><b>${data.manufacturer_detail.name}</b> - ${data.MPN}</span>`;
|
html += ` <span><b>${data.manufacturer_detail.name}</b> - ${data.MPN}</span>`;
|
||||||
html += ` - <i>${data.part_detail.full_name}</i>`;
|
html += ` - <i>${data.part_detail.full_name}</i>`;
|
||||||
|
|
||||||
html += `<span class='float-right'>{% trans "Manufacturer Part ID" %}: ${data.pk}</span>`;
|
html += `<span class='float-right'><small>{% trans "Manufacturer Part ID" %}: ${data.pk}</small></span>`;
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@ -234,7 +271,7 @@ function renderSupplierPart(name, data, parameters, options) {
|
|||||||
html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`;
|
html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`;
|
||||||
html += ` - <i>${data.part_detail.full_name}</i>`;
|
html += ` - <i>${data.part_detail.full_name}</i>`;
|
||||||
|
|
||||||
html += `<span class='float-right'>{% trans "Supplier Part ID" %}: ${data.pk}</span>`;
|
html += `<span class='float-right'><small>{% trans "Supplier Part ID" %}: ${data.pk}</small></span>`;
|
||||||
|
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
|
17
tasks.py
17
tasks.py
@ -127,13 +127,20 @@ def worker(c):
|
|||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def rebuild(c):
|
def rebuild_models(c):
|
||||||
"""
|
"""
|
||||||
Rebuild database models with MPTT structures
|
Rebuild database models with MPTT structures
|
||||||
"""
|
"""
|
||||||
|
|
||||||
manage(c, "rebuild_models")
|
manage(c, "rebuild_models", pty=True)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def rebuild_thumbnails(c):
|
||||||
|
"""
|
||||||
|
Rebuild missing image thumbnails
|
||||||
|
"""
|
||||||
|
|
||||||
|
manage(c, "rebuild_thumbnails", pty=True)
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def clean_settings(c):
|
def clean_settings(c):
|
||||||
@ -143,7 +150,7 @@ def clean_settings(c):
|
|||||||
|
|
||||||
manage(c, "clean_settings")
|
manage(c, "clean_settings")
|
||||||
|
|
||||||
@task(post=[rebuild])
|
@task(post=[rebuild_models, rebuild_thumbnails])
|
||||||
def migrate(c):
|
def migrate(c):
|
||||||
"""
|
"""
|
||||||
Performs database migrations.
|
Performs database migrations.
|
||||||
@ -341,7 +348,7 @@ def export_records(c, filename='data.json'):
|
|||||||
print("Data export completed")
|
print("Data export completed")
|
||||||
|
|
||||||
|
|
||||||
@task(help={'filename': 'Input filename'}, post=[rebuild])
|
@task(help={'filename': 'Input filename'}, post=[rebuild_models, rebuild_thumbnails])
|
||||||
def import_records(c, filename='data.json'):
|
def import_records(c, filename='data.json'):
|
||||||
"""
|
"""
|
||||||
Import database records from a file
|
Import database records from a file
|
||||||
@ -399,7 +406,7 @@ def delete_data(c, force=False):
|
|||||||
manage(c, 'flush')
|
manage(c, 'flush')
|
||||||
|
|
||||||
|
|
||||||
@task(post=[rebuild])
|
@task(post=[rebuild_models, rebuild_thumbnails])
|
||||||
def import_fixtures(c):
|
def import_fixtures(c):
|
||||||
"""
|
"""
|
||||||
Import fixture data into the database.
|
Import fixture data into the database.
|
||||||
|
Loading…
Reference in New Issue
Block a user