Merge branch 'master' of https://github.com/inventree/InvenTree into plugin-2037

This commit is contained in:
Matthias 2021-10-08 22:21:11 +02:00
commit 9facef6a56
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
74 changed files with 3305 additions and 2378 deletions

View File

@ -1,4 +1,5 @@
# Build and push latest docker image on push to master branch
# Build and push docker image on push to 'stable' branch
# Docker build will be uploaded to dockerhub with the 'inventree:stable' tag
name: Docker Build

View File

@ -1,4 +1,5 @@
# Publish docker images to dockerhub
# Publish docker images to dockerhub on a tagged release
# Docker build will be uploaded to dockerhub with the 'invetree:<tag>' tag
name: Docker Publish

37
.github/workflows/docker_test.yaml vendored Normal file
View File

@ -0,0 +1,37 @@
# Test that the InvenTree docker image compiles correctly
# This CI action runs on pushes to either the master or stable branches
# 1. Build the development docker image (as per the documentation)
# 2. Install requied python libs into the docker container
# 3. Launch the container
# 4. Check that the API endpoint is available
name: Docker Test
on:
push:
branches:
- 'master'
- 'stable'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Build Docker Image
run: |
cd docker
docker-compose -f docker-compose.dev.yml build
docker-compose -f docker-compose.dev.yml run inventree-dev-server invoke update
docker-compose -f docker-compose.dev.yml up -d
- name: Sleepy Time
run: sleep 60
- name: Test API
run: |
pip install requests
python3 ci/check_api_endpoint.py

View File

@ -141,3 +141,15 @@ class InvenTreeAPITestCase(APITestCase):
self.assertEqual(response.status_code, expected_code)
return response
def options(self, url, expected_code=None):
"""
Issue an OPTIONS request
"""
response = self.client.options(url, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response

View File

@ -63,6 +63,12 @@ class InvenTreeConfig(AppConfig):
schedule_type=Schedule.DAILY,
)
# Delete old error messages
InvenTree.tasks.schedule_task(
'InvenTree.tasks.delete_old_error_logs',
schedule_type=Schedule.DAILY,
)
# Delete "old" stock items
InvenTree.tasks.schedule_task(
'stock.tasks.delete_old_stock_items',

View File

@ -0,0 +1,70 @@
"""
Custom management command to rebuild thumbnail images
- May be required after importing a new dataset, for example
"""
import os
import logging
from PIL import UnidentifiedImageError
from django.core.management.base import BaseCommand
from django.conf import settings
from django.db.utils import OperationalError, ProgrammingError
from company.models import Company
from part.models import Part
logger = logging.getLogger("inventree-thumbnails")
class Command(BaseCommand):
"""
Rebuild all thumbnail images
"""
def rebuild_thumbnail(self, model):
"""
Rebuild the thumbnail specified by the "image" field of the provided model
"""
if not model.image:
return
img = model.image
url = img.thumbnail.name
loc = os.path.join(settings.MEDIA_ROOT, url)
if not os.path.exists(loc):
logger.info(f"Generating thumbnail image for '{img}'")
try:
model.image.render_variations(replace=False)
except FileNotFoundError:
logger.error(f"ERROR: Image file '{img}' is missing")
except UnidentifiedImageError:
logger.error(f"ERROR: Image file '{img}' is not a valid image")
def handle(self, *args, **kwargs):
logger.setLevel(logging.INFO)
logger.info("Rebuilding Part thumbnails")
for part in Part.objects.exclude(image=None):
try:
self.rebuild_thumbnail(part)
except (OperationalError, ProgrammingError):
logger.error("ERROR: Database read error.")
break
logger.info("Rebuilding Company thumbnails")
for company in Company.objects.exclude(image=None):
try:
self.rebuild_thumbnail(company)
except (OperationalError, ProgrammingError):
logger.error("ERROR: abase read error.")
break

View File

@ -242,6 +242,14 @@
border-color: var(--label-red);
}
.label-form {
margin: 2px;
padding: 3px;
padding-left: 10px;
padding-right: 10px;
border-radius: 5px;
}
.label-red {
background: var(--label-red);
}

View File

@ -156,7 +156,34 @@ def delete_successful_tasks():
started__lte=threshold
)
results.delete()
if results.count() > 0:
logger.info(f"Deleting {results.count()} successful task records")
results.delete()
def delete_old_error_logs():
"""
Delete old error logs from the server
"""
try:
from error_report.models import Error
# Delete any error logs more than 30 days old
threshold = timezone.now() - timedelta(days=30)
errors = Error.objects.filter(
when__lte=threshold,
)
if errors.count() > 0:
logger.info(f"Deleting {errors.count()} old error logs")
errors.delete()
except AppRegistryNotReady:
# Apps not yet loaded
logger.info("Could not perform 'delete_old_error_logs' - App registry not ready")
return
def check_for_updates():
@ -215,7 +242,7 @@ def delete_expired_sessions():
# Delete any sessions that expired more than a day ago
expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1))
if True or expired.count() > 0:
if expired.count() > 0:
logger.info(f"Deleting {expired.count()} expired sessions.")
expired.delete()
@ -247,15 +274,15 @@ def update_exchange_rates():
pass
except:
# Some other error
print("Database not ready")
logger.warning("update_exchange_rates: Database not ready")
return
backend = InvenTreeExchange()
print(f"Updating exchange rates from {backend.url}")
logger.info(f"Updating exchange rates from {backend.url}")
base = currency_code_default()
print(f"Using base currency '{base}'")
logger.info(f"Using base currency '{base}'")
backend.update_rates(base_currency=base)

View File

@ -10,11 +10,23 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev"
INVENTREE_API_VERSION = 12
INVENTREE_API_VERSION = 15
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v15 -> 2021-10-06
- Adds detail endpoint for SalesOrderAllocation model
- Allows use of the API forms interface for adjusting SalesOrderAllocation objects
v14 -> 2021-10-05
- Stock adjustment actions API is improved, using native DRF serializer support
- However adjustment actions now only support 'pk' as a lookup field
v13 -> 2021-10-05
- Adds API endpoint to allocate stock items against a BuildOrder
- Updates StockItem API with improved filtering against BomItem data
v12 -> 2021-09-07
- Adds API endpoint to receive stock items against a PurchaseOrder
@ -96,7 +108,7 @@ def inventreeDocsVersion():
Return the version string matching the latest documentation.
Development -> "latest"
Release -> "major.minor"
Release -> "major.minor.sub" e.g. "0.5.2"
"""
@ -105,7 +117,7 @@ def inventreeDocsVersion():
else:
major, minor, patch = inventreeVersionTuple()
return f"{major}.{minor}"
return f"{major}.{minor}.{patch}"
def isInvenTreeUpToDate():

View File

@ -5,10 +5,12 @@ JSON API for the Build app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include
from rest_framework import filters
from rest_framework import generics
from rest_framework import filters, generics
from rest_framework.serializers import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
@ -19,6 +21,7 @@ from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem, BuildOrderAttachment
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
from .serializers import BuildAllocationSerializer
class BuildFilter(rest_filters.FilterSet):
@ -92,7 +95,7 @@ class BuildList(generics.ListCreateAPIView):
as some of the fields don't natively play nicely with DRF
"""
queryset = super().get_queryset().prefetch_related('part')
queryset = super().get_queryset().select_related('part')
queryset = BuildSerializer.annotate_queryset(queryset)
@ -181,6 +184,58 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
serializer_class = BuildSerializer
class BuildAllocate(generics.CreateAPIView):
"""
API endpoint to allocate stock items to a build order
- The BuildOrder object is specified by the URL
- Items to allocate are specified as a list called "items" with the following options:
- bom_item: pk value of a given BomItem object (must match the part associated with this build)
- stock_item: pk value of a given StockItem object
- quantity: quantity to allocate
- output: StockItem (build order output) to allocate stock against (optional)
"""
queryset = Build.objects.none()
serializer_class = BuildAllocationSerializer
def get_build(self):
"""
Returns the BuildOrder associated with this API endpoint
"""
pk = self.kwargs.get('pk', None)
try:
build = Build.objects.get(pk=pk)
except (Build.DoesNotExist, ValueError):
raise ValidationError(_("Matching build order does not exist"))
return build
def get_serializer_context(self):
"""
Provide the Build object to the serializer context
"""
context = super().get_serializer_context()
context['build'] = self.get_build()
context['request'] = self.request
return context
class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detail view of a BuildItem object
"""
queryset = BuildItem.objects.all()
serializer_class = BuildItemSerializer
class BuildItemList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of BuildItem objects
@ -210,9 +265,9 @@ class BuildItemList(generics.ListCreateAPIView):
query = BuildItem.objects.all()
query = query.select_related('stock_item')
query = query.prefetch_related('stock_item__part')
query = query.prefetch_related('stock_item__part__category')
query = query.select_related('stock_item__location')
query = query.select_related('stock_item__part')
query = query.select_related('stock_item__part__category')
return query
@ -282,16 +337,20 @@ build_api_urls = [
# Attachments
url(r'^attachment/', include([
url(r'^(?P<pk>\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
url('^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
url(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
])),
# Build Items
url(r'^item/', include([
url('^.*$', BuildItemList.as_view(), name='api-build-item-list')
url(r'^(?P<pk>\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'),
url(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'),
])),
# Build Detail
url(r'^(?P<pk>\d+)/', BuildDetail.as_view(), name='api-build-detail'),
url(r'^(?P<pk>\d+)/', include([
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
])),
# Build List
url(r'^.*$', BuildList.as_view(), name='api-build-list'),

View File

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

View File

@ -15,7 +15,7 @@ from InvenTree.fields import DatePickerFormField
from InvenTree.status_codes import StockStatus
from .models import Build, BuildItem
from .models import Build
from stock.models import StockLocation, StockItem
@ -163,18 +163,6 @@ class UnallocateBuildForm(HelperForm):
]
class AutoAllocateForm(HelperForm):
""" Form for auto-allocation of stock to a build """
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation'))
class Meta:
model = Build
fields = [
'confirm',
]
class CompleteBuildForm(HelperForm):
"""
Form for marking a build as complete
@ -256,22 +244,3 @@ class CancelBuildForm(HelperForm):
fields = [
'confirm_cancel'
]
class EditBuildItemForm(HelperForm):
"""
Form for creating (or editing) a BuildItem object.
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'), help_text=_('Select quantity of stock to allocate'))
part_id = forms.IntegerField(required=False, widget=forms.HiddenInput())
class Meta:
model = BuildItem
fields = [
'build',
'stock_item',
'quantity',
'install_into',
]

View File

@ -4,12 +4,14 @@ Build database model definitions
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import decimal
import os
from datetime import datetime
from django.contrib.auth.models import User
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.urls import reverse
@ -584,86 +586,6 @@ class Build(MPTTModel):
self.status = BuildStatus.CANCELLED
self.save()
def getAutoAllocations(self):
"""
Return a list of StockItem objects which will be allocated
using the 'AutoAllocate' function.
For each item in the BOM for the attached Part,
the following tests must *all* evaluate to True,
for the part to be auto-allocated:
- The sub_item in the BOM line must *not* be trackable
- There is only a single stock item available (which has not already been allocated to this build)
- The stock item has an availability greater than zero
Returns:
A list object containing the StockItem objects to be allocated (and the quantities).
Each item in the list is a dict as follows:
{
'stock_item': stock_item,
'quantity': stock_quantity,
}
"""
allocations = []
"""
Iterate through each item in the BOM
"""
for bom_item in self.bom_items:
part = bom_item.sub_part
# If the part is "trackable" it cannot be auto-allocated
if part.trackable:
continue
# Skip any parts which are already fully allocated
if self.isPartFullyAllocated(part, None):
continue
# How many parts are required to complete the output?
required = self.unallocatedQuantity(part, None)
# Grab a list of stock items which are available
stock_items = self.availableStockItems(part, None)
# Ensure that the available stock items are in the correct location
if self.take_from is not None:
# Filter for stock that is located downstream of the designated location
stock_items = stock_items.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()])
# Only one StockItem to choose from? Default to that one!
if stock_items.count() == 1:
stock_item = stock_items[0]
# Double check that we have not already allocated this stock-item against this build
build_items = BuildItem.objects.filter(
build=self,
stock_item=stock_item,
)
if len(build_items) > 0:
continue
# How many items are actually available?
if stock_item.quantity > 0:
# Only take as many as are available
if stock_item.quantity < required:
required = stock_item.quantity
allocation = {
'stock_item': stock_item,
'quantity': required,
}
allocations.append(allocation)
return allocations
@transaction.atomic
def unallocateOutput(self, output, part=None):
"""
@ -803,37 +725,6 @@ class Build(MPTTModel):
# Remove the build output from the database
output.delete()
@transaction.atomic
def autoAllocate(self):
"""
Run auto-allocation routine to allocate StockItems to this Build.
Args:
output: If specified, only auto-allocate against the given built output
Returns a list of dict objects with keys like:
{
'stock_item': item,
'quantity': quantity,
}
See: getAutoAllocations()
"""
allocations = self.getAutoAllocations()
for item in allocations:
# Create a new allocation
build_item = BuildItem(
build=self,
stock_item=item['stock_item'],
quantity=item['quantity'],
install_into=None
)
build_item.save()
@transaction.atomic
def subtractUntrackedStock(self, user):
"""
@ -1165,8 +1056,10 @@ class BuildItem(models.Model):
Attributes:
build: Link to a Build object
bom_item: Link to a BomItem object (may or may not point to the same part as the build)
stock_item: Link to a StockItem object
quantity: Number of units allocated
install_into: Destination stock item (or None)
"""
@staticmethod
@ -1185,35 +1078,13 @@ class BuildItem(models.Model):
def save(self, *args, **kwargs):
self.validate_unique()
self.clean()
super().save()
def validate_unique(self, exclude=None):
"""
Test that this BuildItem object is "unique".
Essentially we do not want a stock_item being allocated to a Build multiple times.
"""
super().validate_unique(exclude)
items = BuildItem.objects.exclude(id=self.id).filter(
build=self.build,
stock_item=self.stock_item,
install_into=self.install_into
)
if items.exists():
msg = _("BuildItem must be unique for build, stock_item and install_into")
raise ValidationError({
'build': msg,
'stock_item': msg,
'install_into': msg
})
def clean(self):
""" Check validity of the BuildItem model.
"""
Check validity of this BuildItem instance.
The following checks are performed:
- StockItem.part must be in the BOM of the Part object referenced by Build
@ -1224,8 +1095,6 @@ class BuildItem(models.Model):
super().clean()
errors = {}
try:
# If the 'part' is trackable, then the 'install_into' field must be set!
@ -1234,29 +1103,39 @@ class BuildItem(models.Model):
# Allocated quantity cannot exceed available stock quantity
if self.quantity > self.stock_item.quantity:
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format(
n=normalize(self.quantity),
q=normalize(self.stock_item.quantity)
)]
q = normalize(self.quantity)
a = normalize(self.stock_item.quantity)
raise ValidationError({
'quantity': _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})')
})
# Allocated quantity cannot cause the stock item to be over-allocated
if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity:
errors['quantity'] = _('StockItem is over-allocated')
available = decimal.Decimal(self.stock_item.quantity)
allocated = decimal.Decimal(self.stock_item.allocation_count())
quantity = decimal.Decimal(self.quantity)
if available - allocated + quantity < quantity:
raise ValidationError({
'quantity': _('Stock item is over-allocated')
})
# Allocated quantity must be positive
if self.quantity <= 0:
errors['quantity'] = _('Allocation quantity must be greater than zero')
raise ValidationError({
'quantity': _('Allocation quantity must be greater than zero'),
})
# Quantity must be 1 for serialized stock
if self.stock_item.serialized and not self.quantity == 1:
errors['quantity'] = _('Quantity must be 1 for serialized stock')
raise ValidationError({
'quantity': _('Quantity must be 1 for serialized stock')
})
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
pass
if len(errors) > 0:
raise ValidationError(errors)
"""
Attempt to find the "BomItem" which links this BuildItem to the build.
@ -1269,7 +1148,7 @@ class BuildItem(models.Model):
"""
A BomItem object has already been assigned. This is valid if:
a) It points to the same "part" as the referened build
a) It points to the same "part" as the referenced build
b) Either:
i) The sub_part points to the same part as the referenced StockItem
ii) The BomItem allows variants and the part referenced by the StockItem
@ -1309,7 +1188,7 @@ class BuildItem(models.Model):
if not bom_item_valid:
raise ValidationError({
'stock_item': _("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)
'stock_item': _("Selected stock item not found in BOM")
})
@transaction.atomic

View File

@ -5,16 +5,25 @@ JSON serializers for Build API
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import ugettext_lazy as _
from django.db.models import Case, When, Value
from django.db.models import BooleanField
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief
from stock.serializers import StockItemSerializerBrief
from stock.serializers import LocationSerializer
import InvenTree.helpers
from stock.models import StockItem
from stock.serializers import StockItemSerializerBrief, LocationSerializer
from part.models import BomItem
from part.serializers import PartSerializer, PartBriefSerializer
from users.serializers import OwnerSerializer
@ -22,7 +31,9 @@ from .models import Build, BuildItem, BuildOrderAttachment
class BuildSerializer(InvenTreeModelSerializer):
""" Serializes a Build object """
"""
Serializes a Build object
"""
url = serializers.CharField(source='get_absolute_url', read_only=True)
status_text = serializers.CharField(source='get_status_display', read_only=True)
@ -109,6 +120,170 @@ class BuildSerializer(InvenTreeModelSerializer):
]
class BuildAllocationItemSerializer(serializers.Serializer):
"""
A serializer for allocating a single stock item against a build order
"""
bom_item = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('BOM Item'),
)
def validate_bom_item(self, bom_item):
build = self.context['build']
# BomItem must point to the same 'part' as the parent build
if build.part != bom_item.part:
raise ValidationError(_("bom_item.part must point to the same part as the build order"))
return bom_item
stock_item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Stock Item'),
)
def validate_stock_item(self, stock_item):
if not stock_item.in_stock:
raise ValidationError(_("Item must be in stock"))
return stock_item
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
required=True
)
def validate_quantity(self, quantity):
if quantity <= 0:
raise ValidationError(_("Quantity must be greater than zero"))
return quantity
output = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.filter(is_building=True),
many=False,
allow_null=True,
required=False,
label=_('Build Output'),
)
class Meta:
fields = [
'bom_item',
'stock_item',
'quantity',
'output',
]
def validate(self, data):
super().validate(data)
bom_item = data['bom_item']
stock_item = data['stock_item']
quantity = data['quantity']
output = data.get('output', None)
# build = self.context['build']
# TODO: Check that the "stock item" is valid for the referenced "sub_part"
# Note: Because of allow_variants options, it may not be a direct match!
# Check that the quantity does not exceed the available amount from the stock item
q = stock_item.unallocated_quantity()
if quantity > q:
q = InvenTree.helpers.clean_decimal(q)
raise ValidationError({
'quantity': _(f"Available quantity ({q}) exceeded")
})
# Output *must* be set for trackable parts
if output is None and bom_item.sub_part.trackable:
raise ValidationError({
'output': _('Build output must be specified for allocation of tracked parts')
})
# Output *cannot* be set for un-tracked parts
if output is not None and not bom_item.sub_part.trackable:
raise ValidationError({
'output': _('Build output cannot be specified for allocation of untracked parts')
})
return data
class BuildAllocationSerializer(serializers.Serializer):
"""
DRF serializer for allocation stock items against a build order
"""
items = BuildAllocationItemSerializer(many=True)
class Meta:
fields = [
'items',
]
def validate(self, data):
"""
Validation
"""
super().validate(data)
items = data.get('items', [])
if len(items) == 0:
raise ValidationError(_('Allocation items must be provided'))
return data
def save(self):
data = self.validated_data
items = data.get('items', [])
build = self.context['build']
with transaction.atomic():
for item in items:
bom_item = item['bom_item']
stock_item = item['stock_item']
quantity = item['quantity']
output = item.get('output', None)
try:
# Create a new BuildItem to allocate stock
BuildItem.objects.create(
build=build,
bom_item=bom_item,
stock_item=stock_item,
quantity=quantity,
install_into=output
)
except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors
raise ValidationError(detail=serializers.as_serializer_error(exc))
class BuildItemSerializer(InvenTreeModelSerializer):
""" Serializes a BuildItem object """

View File

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

View File

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

View File

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

View File

@ -170,7 +170,7 @@
{% if build.active %}
<div class='btn-group' role='group'>
<button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
<span class='fas fa-sign-in-alt'></span> {% trans "Allocate Stock" %}
</button>
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
@ -191,7 +191,19 @@
</div>
{% endif %}
{% endif %}
<table class='table table-striped table-condensed' id='allocation-table-untracked'></table>
<div id='unallocated-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
<span class='fas fa-sign-in-alt'></span>
</button>
<div class='filter-list' id='filter-list-build-items'>
<!-- Empty div for table filters-->
</div>
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='allocation-table-untracked' data-toolbar='#unallocated-toolbar'></table>
{% else %}
<div class='alert alert-block alert-info'>
{% trans "This Build Order does not have any associated untracked BOM items" %}
@ -292,6 +304,7 @@ loadStockTable($("#build-stock-table"), {
location_detail: true,
part_detail: true,
build: {{ build.id }},
is_building: false,
},
groupByField: 'location',
buttons: [
@ -305,6 +318,9 @@ var buildInfo = {
quantity: {{ build.quantity }},
completed: {{ build.completed }},
part: {{ build.part.pk }},
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
};
{% for item in build.incomplete_outputs %}
@ -400,13 +416,6 @@ $('#edit-notes').click(function() {
});
});
var buildInfo = {
pk: {{ build.pk }},
quantity: {{ build.quantity }},
completed: {{ build.completed }},
part: {{ build.part.pk }},
};
{% if build.has_untracked_bom_items %}
// Load allocation table for un-tracked parts
loadBuildOutputAllocationTable(buildInfo, null);
@ -418,12 +427,38 @@ function reloadTable() {
{% if build.active %}
$("#btn-auto-allocate").on('click', function() {
launchModalForm(
"{% url 'build-auto-allocate' build.id %}",
{
success: reloadTable,
var bom_items = $("#allocation-table-untracked").bootstrapTable("getData");
var incomplete_bom_items = [];
bom_items.forEach(function(bom_item) {
if (bom_item.required > bom_item.allocated) {
incomplete_bom_items.push(bom_item);
}
);
});
if (incomplete_bom_items.length == 0) {
showAlertDialog(
'{% trans "Allocation Complete" %}',
'{% trans "All untracked stock items have been allocated" %}',
);
} else {
allocateStockToBuild(
{{ build.pk }},
{{ build.part.pk }},
incomplete_bom_items,
{
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
success: function(data) {
$('#allocation-table-untracked').bootstrapTable('refresh');
}
}
);
}
});
$('#btn-unallocate').on('click', function() {
@ -435,6 +470,25 @@ $('#btn-unallocate').on('click', function() {
);
});
$('#allocate-selected-items').click(function() {
var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections");
allocateStockToBuild(
{{ build.pk }},
{{ build.part.pk }},
bom_items,
{
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
success: function(data) {
$('#allocation-table-untracked').bootstrapTable('refresh');
}
}
);
});
$("#btn-order-parts").click(function() {
launchModalForm("/order/purchase-order/order-parts/", {
data: {

View File

@ -6,7 +6,7 @@ from datetime import datetime, timedelta
from django.urls import reverse
from part.models import Part
from build.models import Build
from build.models import Build, BuildItem
from InvenTree.status_codes import BuildStatus
from InvenTree.api_tester import InvenTreeAPITestCase
@ -23,6 +23,7 @@ class BuildAPITest(InvenTreeAPITestCase):
'location',
'bom',
'build',
'stock',
]
# Required roles to access Build API endpoints
@ -36,6 +37,192 @@ class BuildAPITest(InvenTreeAPITestCase):
super().setUp()
class BuildAllocationTest(BuildAPITest):
"""
Unit tests for allocation of stock items against a build order.
For this test, we will be using Build ID=1;
- This points to Part 100 (see fixture data in part.yaml)
- This Part already has a BOM with 4 items (see fixture data in bom.yaml)
- There are no BomItem objects yet created for this build
"""
def setUp(self):
super().setUp()
self.assignRole('build.add')
self.assignRole('build.change')
self.url = reverse('api-build-allocate', kwargs={'pk': 1})
self.build = Build.objects.get(pk=1)
# Record number of build items which exist at the start of each test
self.n = BuildItem.objects.count()
def test_build_data(self):
"""
Check that our assumptions about the particular BuildOrder are correct
"""
self.assertEqual(self.build.part.pk, 100)
# There should be 4x BOM items we can use
self.assertEqual(self.build.part.bom_items.count(), 4)
# No items yet allocated to this build
self.assertEqual(self.build.allocated_stock.count(), 0)
def test_get(self):
"""
A GET request to the endpoint should return an error
"""
self.get(self.url, expected_code=405)
def test_options(self):
"""
An OPTIONS request to the endpoint should return information about the endpoint
"""
response = self.options(self.url, expected_code=200)
self.assertIn("API endpoint to allocate stock items to a build order", str(response.data))
def test_empty(self):
"""
Test without any POST data
"""
# Initially test with an empty data set
data = self.post(self.url, {}, expected_code=400).data
self.assertIn('This field is required', str(data['items']))
# Now test but with an empty items list
data = self.post(
self.url,
{
"items": []
},
expected_code=400
).data
self.assertIn('Allocation items must be provided', str(data))
# No new BuildItem objects have been created during this test
self.assertEqual(self.n, BuildItem.objects.count())
def test_missing(self):
"""
Test with missing data
"""
# Missing quantity
data = self.post(
self.url,
{
"items": [
{
"bom_item": 1, # M2x4 LPHS
"stock_item": 2, # 5,000 screws available
}
]
},
expected_code=400
).data
self.assertIn('This field is required', str(data["items"][0]["quantity"]))
# Missing bom_item
data = self.post(
self.url,
{
"items": [
{
"stock_item": 2,
"quantity": 5000,
}
]
},
expected_code=400
).data
self.assertIn("This field is required", str(data["items"][0]["bom_item"]))
# Missing stock_item
data = self.post(
self.url,
{
"items": [
{
"bom_item": 1,
"quantity": 5000,
}
]
},
expected_code=400
).data
self.assertIn("This field is required", str(data["items"][0]["stock_item"]))
# No new BuildItem objects have been created during this test
self.assertEqual(self.n, BuildItem.objects.count())
def test_invalid_bom_item(self):
"""
Test by passing an invalid BOM item
"""
data = self.post(
self.url,
{
"items": [
{
"bom_item": 5,
"stock_item": 11,
"quantity": 500,
}
]
},
expected_code=400
).data
self.assertIn('must point to the same part', str(data))
def test_valid_data(self):
"""
Test with valid data.
This should result in creation of a new BuildItem object
"""
self.post(
self.url,
{
"items": [
{
"bom_item": 1,
"stock_item": 2,
"quantity": 5000,
}
]
},
expected_code=201
)
# A new BuildItem should have been created
self.assertEqual(self.n + 1, BuildItem.objects.count())
allocation = BuildItem.objects.last()
self.assertEqual(allocation.quantity, 5000)
self.assertEqual(allocation.bom_item.pk, 1)
self.assertEqual(allocation.stock_item.pk, 2)
class BuildListTest(BuildAPITest):
"""
Tests for the BuildOrder LIST API

View File

@ -269,25 +269,6 @@ class BuildTest(TestCase):
self.assertTrue(self.build.areUntrackedPartsFullyAllocated())
def test_auto_allocate(self):
"""
Test auto-allocation functionality against the build outputs.
Note: auto-allocations only work for un-tracked stock!
"""
allocations = self.build.getAutoAllocations()
self.assertEqual(len(allocations), 1)
self.build.autoAllocate()
self.assertEqual(BuildItem.objects.count(), 1)
# Check that one un-tracked part has been fully allocated to the build
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, None))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, None))
def test_cancel(self):
"""
Test cancellation of the build

View File

@ -172,7 +172,7 @@ class TestBuildAPI(APITestCase):
# Filter by 'part' status
response = self.client.get(url, {'part': 25}, format='json')
self.assertEqual(len(response.data), 2)
self.assertEqual(len(response.data), 1)
# Filter by an invalid part
response = self.client.get(url, {'part': 99999}, format='json')
@ -252,34 +252,6 @@ class TestBuildViews(TestCase):
self.assertIn(build.title, content)
def test_build_item_create(self):
""" Test the BuildItem creation view (ajax form) """
url = reverse('build-item-create')
# Try without a part specified
response = self.client.get(url, {'build': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Try with an invalid build ID
response = self.client.get(url, {'build': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Try with a valid part specified
response = self.client.get(url, {'build': 1, 'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Try with an invalid part specified
response = self.client.get(url, {'build': 1, 'part': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_build_item_edit(self):
""" Test the BuildItem edit view (ajax form) """
# TODO
# url = reverse('build-item-edit')
pass
def test_build_output_complete(self):
"""
Test the build output completion form

View File

@ -12,7 +12,6 @@ build_detail_urls = [
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'),
url(r'^auto-allocate/', views.BuildAutoAllocate.as_view(), name='build-auto-allocate'),
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
@ -20,13 +19,6 @@ build_detail_urls = [
]
build_urls = [
url(r'item/', include([
url(r'^(?P<pk>\d+)/', include([
url('^edit/', views.BuildItemEdit.as_view(), name='build-item-edit'),
url('^delete/', views.BuildItemDelete.as_view(), name='build-item-delete'),
])),
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
])),
url(r'^(?P<pk>\d+)/', include(build_detail_urls)),

View File

@ -11,13 +11,13 @@ from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
from part.models import Part
from .models import Build, BuildItem
from .models import Build
from . import forms
from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool, extract_serial_numbers, normalize, isNull
from InvenTree.helpers import str2bool, extract_serial_numbers, isNull
from InvenTree.status_codes import BuildStatus, StockStatus
@ -77,67 +77,6 @@ class BuildCancel(AjaxUpdateView):
}
class BuildAutoAllocate(AjaxUpdateView):
""" View to auto-allocate parts for a build.
Follows a simple set of rules to automatically allocate StockItem objects.
Ref: build.models.Build.getAutoAllocations()
"""
model = Build
form_class = forms.AutoAllocateForm
context_object_name = 'build'
ajax_form_title = _('Allocate Stock')
ajax_template_name = 'build/auto_allocate.html'
def get_initial(self):
"""
Initial values for the form.
"""
initials = super().get_initial()
return initials
def get_context_data(self, *args, **kwargs):
"""
Get the context data for form rendering.
"""
context = {}
build = self.get_object()
context['allocations'] = build.getAutoAllocations()
context['build'] = build
return context
def get_form(self):
form = super().get_form()
return form
def validate(self, build, form, **kwargs):
pass
def save(self, build, form, **kwargs):
"""
Once the form has been validated,
perform auto-allocations
"""
build.autoAllocate()
def get_data(self):
return {
'success': _('Allocated stock to build output'),
}
class BuildOutputCreate(AjaxUpdateView):
"""
Create a new build output (StockItem) for a given build.
@ -626,268 +565,3 @@ class BuildDelete(AjaxDeleteView):
model = Build
ajax_template_name = 'build/delete_build.html'
ajax_form_title = _('Delete Build Order')
class BuildItemDelete(AjaxDeleteView):
""" View to 'unallocate' a BuildItem.
Really we are deleting the BuildItem object from the database.
"""
model = BuildItem
ajax_template_name = 'build/delete_build_item.html'
ajax_form_title = _('Unallocate Stock')
context_object_name = 'item'
def get_data(self):
return {
'danger': _('Removed parts from build allocation')
}
class BuildItemCreate(AjaxCreateView):
"""
View for allocating a StockItem to a build output.
"""
model = BuildItem
form_class = forms.EditBuildItemForm
ajax_template_name = 'build/create_build_item.html'
ajax_form_title = _('Allocate stock to build output')
# The output StockItem against which the allocation is being made
output = None
# The "part" which is being allocated to the output
part = None
available_stock = None
def get_context_data(self):
"""
Provide context data to the template which renders the form.
"""
ctx = super().get_context_data()
if self.part:
ctx['part'] = self.part
if self.output:
ctx['output'] = self.output
if self.available_stock:
ctx['stock'] = self.available_stock
else:
ctx['no_stock'] = True
return ctx
def validate(self, build_item, form, **kwargs):
"""
Extra validation steps as required
"""
data = form.cleaned_data
stock_item = data.get('stock_item', None)
quantity = data.get('quantity', None)
if stock_item:
# Stock item must actually be in stock!
if not stock_item.in_stock:
form.add_error('stock_item', _('Item must be currently in stock'))
# Check that there are enough items available
if quantity is not None:
available = stock_item.unallocated_quantity()
if quantity > available:
form.add_error('stock_item', _('Stock item is over-allocated'))
form.add_error('quantity', _('Available') + ': ' + str(normalize(available)))
else:
form.add_error('stock_item', _('Stock item must be selected'))
def get_form(self):
""" Create Form for making / editing new Part object """
form = super(AjaxCreateView, self).get_form()
self.build = None
self.part = None
self.output = None
# If the Build object is specified, hide the input field.
# We do not want the users to be able to move a BuildItem to a different build
build_id = form['build'].value()
if build_id is not None:
"""
If the build has been provided, hide the widget to change the build selection.
Additionally, update the allowable selections for other fields.
"""
form.fields['build'].widget = HiddenInput()
form.fields['install_into'].queryset = StockItem.objects.filter(build=build_id, is_building=True)
self.build = Build.objects.get(pk=build_id)
else:
"""
Build has *not* been selected
"""
pass
# If the sub_part is supplied, limit to matching stock items
part_id = form['part_id'].value()
if part_id:
try:
self.part = Part.objects.get(pk=part_id)
except (ValueError, Part.DoesNotExist):
pass
# If the output stock item is specified, hide the input field
output_id = form['install_into'].value()
if output_id is not None:
try:
self.output = StockItem.objects.get(pk=output_id)
form.fields['install_into'].widget = HiddenInput()
except (ValueError, StockItem.DoesNotExist):
pass
else:
# If the output is not specified, but we know that the part is non-trackable, hide the install_into field
if self.part and not self.part.trackable:
form.fields['install_into'].widget = HiddenInput()
if self.build and self.part:
available_items = self.build.availableStockItems(self.part, self.output)
form.fields['stock_item'].queryset = available_items
self.available_stock = form.fields['stock_item'].queryset.all()
# If there is only a single stockitem available, select it!
if len(self.available_stock) == 1:
form.fields['stock_item'].initial = self.available_stock[0].pk
return form
def get_initial(self):
""" Provide initial data for BomItem. Look for the folllowing in the GET data:
- build: pk of the Build object
- part: pk of the Part object which we are assigning
- output: pk of the StockItem object into which the allocated stock will be installed
"""
initials = super(AjaxCreateView, self).get_initial().copy()
build_id = self.get_param('build')
part_id = self.get_param('part')
output_id = self.get_param('install_into')
# Reference to a Part object
part = None
# Reference to a StockItem object
item = None
# Reference to a Build object
build = None
# Reference to a StockItem object
output = None
if part_id:
try:
part = Part.objects.get(pk=part_id)
initials['part_id'] = part.pk
except Part.DoesNotExist:
pass
if build_id:
try:
build = Build.objects.get(pk=build_id)
initials['build'] = build
except Build.DoesNotExist:
pass
# If the output has been specified
if output_id:
try:
output = StockItem.objects.get(pk=output_id)
initials['install_into'] = output
except (ValueError, StockItem.DoesNotExist):
pass
# Work out how much stock is required
if build and part:
required_quantity = build.unallocatedQuantity(part, output)
else:
required_quantity = None
quantity = self.request.GET.get('quantity', None)
if quantity is not None:
quantity = float(quantity)
elif required_quantity is not None:
quantity = required_quantity
item_id = self.get_param('item')
# If the request specifies a particular StockItem
if item_id:
try:
item = StockItem.objects.get(pk=item_id)
except (ValueError, StockItem.DoesNotExist):
pass
# If a StockItem is not selected, try to auto-select one
if item is None and part is not None:
items = StockItem.objects.filter(part=part)
if items.count() == 1:
item = items.first()
# Finally, if a StockItem is selected, ensure the quantity is not too much
if item is not None:
if quantity is None:
quantity = item.unallocated_quantity()
else:
quantity = min(quantity, item.unallocated_quantity())
if quantity is not None:
initials['quantity'] = quantity
return initials
class BuildItemEdit(AjaxUpdateView):
""" View to edit a BuildItem object """
model = BuildItem
ajax_template_name = 'build/edit_build_item.html'
form_class = forms.EditBuildItemForm
ajax_form_title = _('Edit Stock Allocation')
def get_data(self):
return {
'info': _('Updated Build Item'),
}
def get_form(self):
"""
Create form for editing a BuildItem.
- Limit the StockItem options to items that match the part
"""
form = super(BuildItemEdit, self).get_form()
# Hide fields which we do not wish the user to edit
for field in ['build', 'stock_item']:
if form[field].value():
form.fields[field].widget = HiddenInput()
form.fields['install_into'].widget = HiddenInput()
return form

View File

@ -649,14 +649,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
},
# TODO: Remove this setting in future, new API forms make this not useful
'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'),
'default': True,
'validator': bool,
},
'PART_SHOW_IMPORT': {
'name': _('Show Import in Views'),
'description': _('Display the import wizard in some part views'),
@ -671,6 +663,18 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
},
# 2021-10-08
# This setting exists as an interim solution for https://github.com/inventree/InvenTree/issues/2042
# The BOM API can be extremely slow when calculating pricing information "on the fly"
# A future solution will solve this properly,
# but as an interim step we provide a global to enable / disable BOM pricing
'PART_SHOW_PRICE_IN_BOM': {
'name': _('Show Price in BOM'),
'description': _('Include pricing information in BOM tables'),
'default': True,
'validator': bool,
},
'PART_SHOW_RELATED': {
'name': _('Show related parts'),
'description': _('Display related parts for a part'),
@ -973,6 +977,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'),
'default': True,
'validator': bool,
},
}
class Meta:

View File

@ -1,18 +1,6 @@
from __future__ import unicode_literals
import os
import logging
from PIL import UnidentifiedImageError
from django.apps import AppConfig
from django.db.utils import OperationalError, ProgrammingError
from django.conf import settings
from InvenTree.ready import canAppAccessDatabase
logger = logging.getLogger("inventree")
class CompanyConfig(AppConfig):
@ -23,29 +11,4 @@ class CompanyConfig(AppConfig):
This function is called whenever the Company app is loaded.
"""
if canAppAccessDatabase():
self.generate_company_thumbs()
def generate_company_thumbs(self):
from .models import Company
logger.debug("Checking Company image thumbnails")
try:
for company in Company.objects.all():
if company.image:
url = company.image.thumbnail.name
loc = os.path.join(settings.MEDIA_ROOT, url)
if not os.path.exists(loc):
logger.info("InvenTree: Generating thumbnail for Company '{c}'".format(c=company.name))
try:
company.image.render_variations(replace=False)
except FileNotFoundError:
logger.warning(f"Image file '{company.image}' missing")
except UnidentifiedImageError:
logger.warning(f"Image file '{company.image}' is invalid")
except (OperationalError, ProgrammingError):
# Getting here probably meant the database was in test mode
pass
pass

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.5 on 2021-10-04 20:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('company', '0040_alter_company_currency'),
]
operations = [
migrations.AlterModelOptions(
name='company',
options={'ordering': ['name'], 'verbose_name_plural': 'Companies'},
),
]

View File

@ -94,6 +94,7 @@ class Company(models.Model):
constraints = [
UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair')
]
verbose_name_plural = "Companies"
name = models.CharField(max_length=100, blank=False,
help_text=_('Company name'),

View File

@ -158,6 +158,12 @@
function reloadImage(data) {
if (data.image) {
$('#company-image').attr('src', data.image);
// Reset the "modal image" view
$('#company-image').click(function() {
showModalImage(data.image);
});
} else {
location.reload();
}

View File

@ -77,6 +77,14 @@ class POLineItemResource(ModelResource):
class SOLineItemResource(ModelResource):
""" Class for managing import / export of SOLineItem data """
part_name = Field(attribute='part__name', readonly=True)
IPN = Field(attribute='part__IPN', readonly=True)
description = Field(attribute='part__description', readonly=True)
fulfilled = Field(attribute='fulfilled_quantity', readonly=True)
class Meta:
model = SalesOrderLineItem
skip_unchanged = True

View File

@ -7,14 +7,12 @@ from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include
from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db.models import Q, F
from django_filters import rest_framework as rest_filters
from rest_framework import generics
from rest_framework import filters, status
from rest_framework.response import Response
from rest_framework import serializers
from rest_framework.serializers import ValidationError
@ -235,6 +233,7 @@ class POReceive(generics.CreateAPIView):
# Pass the purchase order through to the serializer for validation
context['order'] = self.get_order()
context['request'] = self.request
return context
@ -252,75 +251,38 @@ class POReceive(generics.CreateAPIView):
return order
def create(self, request, *args, **kwargs):
# Which purchase order are we receiving against?
self.order = self.get_order()
class POLineItemFilter(rest_filters.FilterSet):
"""
Custom filters for the POLineItemList endpoint
"""
# Validate the serialized data
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
class Meta:
model = PurchaseOrderLineItem
fields = [
'order',
'part'
]
# Receive the line items
try:
self.receive_items(serializer)
except DjangoValidationError as exc:
# Re-throw a django error as a DRF error
raise ValidationError(detail=serializers.as_serializer_error(exc))
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@transaction.atomic
def receive_items(self, serializer):
def filter_completed(self, queryset, name, value):
"""
Receive the items
Filter by lines which are "completed" (or "not" completed)
At this point, much of the heavy lifting has been done for us by DRF serializers!
We have a list of "items", each a dict which contains:
- line_item: A PurchaseOrderLineItem matching this order
- location: A destination location
- quantity: A validated numerical quantity
- status: The status code for the received item
A line is completed when received >= quantity
"""
data = serializer.validated_data
value = str2bool(value)
location = data['location']
q = Q(received__gte=F('quantity'))
items = data['items']
if value:
queryset = queryset.filter(q)
else:
queryset = queryset.exclude(q)
# Check if the location is not specified for any particular item
for item in items:
line = item['line_item']
if not item.get('location', None):
# If a global location is specified, use that
item['location'] = location
if not item['location']:
# The line item specifies a location?
item['location'] = line.get_destination()
if not item['location']:
raise ValidationError({
'location': _("Destination location must be specified"),
})
# Now we can actually receive the items
for item in items:
self.order.receive_line_item(
item['line_item'],
item['location'],
item['quantity'],
self.request.user,
status=item['status'],
barcode=item.get('barcode', ''),
)
return queryset
class POLineItemList(generics.ListCreateAPIView):
@ -332,6 +294,7 @@ class POLineItemList(generics.ListCreateAPIView):
queryset = PurchaseOrderLineItem.objects.all()
serializer_class = POLineItemSerializer
filterset_class = POLineItemFilter
def get_queryset(self, *args, **kwargs):
@ -668,6 +631,15 @@ class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = SOLineItemSerializer
class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detali view of a SalesOrderAllocation object
"""
queryset = SalesOrderAllocation.objects.all()
serializer_class = SalesOrderAllocationSerializer
class SOAllocationList(generics.ListCreateAPIView):
"""
API endpoint for listing SalesOrderAllocation objects
@ -780,8 +752,10 @@ order_api_urls = [
])),
# API endpoints for purchase order line items
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'),
url(r'^po-line/', include([
url(r'^(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
url(r'^.*$', POLineItemList.as_view(), name='api-po-line-list'),
])),
# API endpoints for sales ordesr
url(r'^so/', include([
@ -801,9 +775,8 @@ order_api_urls = [
])),
# API endpoints for sales order allocations
url(r'^so-allocation', include([
# List all sales order allocations
url(r'^so-allocation/', include([
url(r'^(?P<pk>\d+)/$', SOAllocationDetail.as_view(), name='api-so-allocation-detail'),
url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'),
])),
]

View File

@ -8,8 +8,6 @@ from __future__ import unicode_literals
from django import forms
from django.utils.translation import ugettext_lazy as _
from mptt.fields import TreeNodeChoiceField
from InvenTree.forms import HelperForm
from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField
@ -19,7 +17,6 @@ from common.forms import MatchItemForm
import part.models
from stock.models import StockLocation
from .models import PurchaseOrder
from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
@ -80,22 +77,6 @@ class ShipSalesOrderForm(HelperForm):
]
class ReceivePurchaseOrderForm(HelperForm):
location = TreeNodeChoiceField(
queryset=StockLocation.objects.all(),
required=False,
label=_("Destination"),
help_text=_("Set all received parts listed above to this location (if left blank, use \"Destination\" column value in above table)"),
)
class Meta:
model = PurchaseOrder
fields = [
"location",
]
class AllocateSerialsToSalesOrderForm(forms.Form):
"""
Form for assigning stock to a sales order,
@ -134,23 +115,6 @@ class AllocateSerialsToSalesOrderForm(forms.Form):
]
class CreateSalesOrderAllocationForm(HelperForm):
"""
Form for creating a SalesOrderAllocation item.
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
class Meta:
model = SalesOrderAllocation
fields = [
'line',
'item',
'quantity',
]
class EditSalesOrderAllocationForm(HelperForm):
"""
Form for editing a SalesOrderAllocation item

View File

@ -840,7 +840,13 @@ class SalesOrderLineItem(OrderLineItem):
def get_api_url():
return reverse('api-so-line-list')
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), help_text=_('Sales Order'))
order = models.ForeignKey(
SalesOrder,
on_delete=models.CASCADE,
related_name='lines',
verbose_name=_('Order'),
help_text=_('Sales Order')
)
part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True})
@ -954,7 +960,11 @@ class SalesOrderAllocation(models.Model):
if len(errors) > 0:
raise ValidationError(errors)
line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), related_name='allocations')
line = models.ForeignKey(
SalesOrderLineItem,
on_delete=models.CASCADE,
verbose_name=_('Line'),
related_name='allocations')
item = models.ForeignKey(
'stock.StockItem',

View File

@ -7,7 +7,8 @@ from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
from django.db import models
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models, transaction
from django.db.models import Case, When, Value
from django.db.models import BooleanField, ExpressionWrapper, F
@ -224,6 +225,13 @@ class POLineItemReceiveSerializer(serializers.Serializer):
required=True,
)
def validate_quantity(self, quantity):
if quantity <= 0:
raise ValidationError(_("Quantity must be greater than zero"))
return quantity
status = serializers.ChoiceField(
choices=list(StockStatus.items()),
default=StockStatus.OK,
@ -235,6 +243,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
help_text=_('Unique identifier field'),
default='',
required=False,
allow_blank=True,
)
def validate_barcode(self, barcode):
@ -244,7 +253,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
# Ignore empty barcode values
if not barcode or barcode.strip() == '':
return
return None
if stock.models.StockItem.objects.filter(uid=barcode).exists():
raise ValidationError(_('Barcode is already in use'))
@ -276,35 +285,81 @@ class POReceiveSerializer(serializers.Serializer):
help_text=_('Select destination location for received items'),
)
def is_valid(self, raise_exception=False):
def validate(self, data):
super().is_valid(raise_exception)
# Custom validation
data = self.validated_data
super().validate(data)
items = data.get('items', [])
location = data.get('location', None)
if len(items) == 0:
self._errors['items'] = _('Line items must be provided')
else:
# Ensure barcodes are unique
unique_barcodes = set()
raise ValidationError(_('Line items must be provided'))
# Check if the location is not specified for any particular item
for item in items:
line = item['line_item']
if not item.get('location', None):
# If a global location is specified, use that
item['location'] = location
if not item['location']:
# The line item specifies a location?
item['location'] = line.get_destination()
if not item['location']:
raise ValidationError({
'location': _("Destination location must be specified"),
})
# Ensure barcodes are unique
unique_barcodes = set()
for item in items:
barcode = item.get('barcode', '')
if barcode:
if barcode in unique_barcodes:
raise ValidationError(_('Supplied barcode values must be unique'))
else:
unique_barcodes.add(barcode)
return data
def save(self):
"""
Perform the actual database transaction to receive purchase order items
"""
data = self.validated_data
request = self.context['request']
order = self.context['order']
items = data['items']
location = data.get('location', None)
# Now we can actually receive the items into stock
with transaction.atomic():
for item in items:
barcode = item.get('barcode', '')
if barcode:
if barcode in unique_barcodes:
self._errors['items'] = _('Supplied barcode values must be unique')
break
else:
unique_barcodes.add(barcode)
# Select location
loc = item.get('location', None) or item['line_item'].get_destination() or location
if self._errors and raise_exception:
raise ValidationError(self.errors)
return not bool(self._errors)
try:
order.receive_line_item(
item['line_item'],
loc,
item['quantity'],
request.user,
status=item['status'],
barcode=item.get('barcode', ''),
)
except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors
raise ValidationError(detail=serializers.as_serializer_error(exc))
class Meta:
fields = [
@ -423,7 +478,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True)
serial = serializers.CharField(source='get_serial', read_only=True)
quantity = serializers.FloatField(read_only=True)
quantity = serializers.FloatField(read_only=False)
location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True)
# Extra detail fields
@ -494,7 +549,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
quantity = serializers.FloatField()

View File

@ -39,6 +39,9 @@ src="{% static 'img/blank_image.png' %}"
<button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'>
<span class='fas fa-print'></span>
</button>
<button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'>
<span class='fas fa-file-download'></span>
</button>
{% if roles.purchase_order.change %}
<button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'>
<span class='fas fa-edit icon-green'></span>
@ -49,7 +52,7 @@ src="{% static 'img/blank_image.png' %}"
</button>
{% elif order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-default' id='receive-order' title='{% trans "Receive items" %}'>
<span class='fas fa-clipboard-check'></span>
<span class='fas fa-sign-in-alt'></span>
</button>
<button type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'>
<span class='fas fa-check-circle'></span>
@ -61,9 +64,6 @@ src="{% static 'img/blank_image.png' %}"
</button>
{% endif %}
{% endif %}
<button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'>
<span class='fas fa-file-download'></span>
</button>
</div>
</div>
{% endblock %}
@ -188,17 +188,27 @@ $("#edit-order").click(function() {
});
$("#receive-order").click(function() {
launchModalForm("{% url 'po-receive' order.id %}", {
reload: true,
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: "{% url 'stock-location-create' %}",
},
]
// Auto select items which have not been fully allocated
var items = $("#po-line-table").bootstrapTable('getData');
var items_to_receive = [];
items.forEach(function(item) {
if (item.received < item.quantity) {
items_to_receive.push(item);
}
});
receivePurchaseOrderItems(
{{ order.id }},
items_to_receive,
{
success: function() {
$("#po-line-table").bootstrapTable('refresh');
}
}
);
});
$("#complete-order").click(function() {
@ -214,7 +224,7 @@ $("#cancel-order").click(function() {
});
$("#export-order").click(function() {
location.href = "{% url 'po-export' order.id %}";
exportOrder('{% url "po-export" order.id %}');
});

View File

@ -18,14 +18,23 @@
</div>
<div class='panel-content'>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
{% if roles.purchase_order.change %}
{% if order.status == PurchaseOrderStatus.PENDING %}
<button type='button' class='btn btn-success' id='new-po-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button>
<a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'>
<span class='fas fa-file-upload side-icon'></span> {% trans "Upload File" %}
</a>
{% elif order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-success' id='receive-selected-items' title='{% trans "Receive selected items" %}'>
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Items" %}
</button>
{% endif %}
{% endif %}
<div class='filter-list' id='filter-list-purchase-order-lines'>
<!-- An empty div in which the filter list will be constructed-->
</div>
</div>
<table class='table table-striped table-condensed' id='po-line-table' data-toolbar='#order-toolbar-buttons'>
@ -207,6 +216,22 @@ $('#new-po-line').click(function() {
});
});
{% elif order.status == PurchaseOrderStatus.PLACED %}
$('#receive-selected-items').click(function() {
var items = $("#po-line-table").bootstrapTable('getSelections');
receivePurchaseOrderItems(
{{ order.id }},
items,
{
success: function() {
$("#po-line-table").bootstrapTable('refresh');
}
}
);
});
{% endif %}
loadPurchaseOrderLineItemTable('#po-line-table', {

View File

@ -1,81 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% load status_codes %}
{% block form %}
{% blocktrans with desc=order.description %}Receive outstanding parts for <strong>{{order}}</strong> - <em>{{desc}}</em>{% endblocktrans %}
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
{% csrf_token %}
{% load crispy_forms_tags %}
<label class='control-label'>{% trans "Parts" %}</label>
<p class='help-block'>{% trans "Fill out number of parts received, the status and destination" %}</p>
<table class='table table-striped'>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Order Code" %}</th>
<th>{% trans "On Order" %}</th>
<th>{% trans "Received" %}</th>
<th>{% trans "Receive" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Destination" %}</th>
<th></th>
</tr>
{% for line in lines %}
<tr id='line_row_{{ line.id }}'>
{% if line.part %}
<td>
{% include "hover_image.html" with image=line.part.part.image hover=False %}
{{ line.part.part.full_name }}
</td>
<td>{{ line.part.SKU }}</td>
{% else %}
<td colspan='2'>{% trans "Error: Referenced part has been removed" %}</td>
{% endif %}
<td>{% decimal line.quantity %}</td>
<td>{% decimal line.received %}</td>
<td>
<div class='control-group'>
<div class='controls'>
<input class='numberinput' type='number' min='0' value='{% decimal line.receive_quantity %}' name='line-{{ line.id }}'/>
</div>
</div>
</td>
<td>
<div class='control-group'>
<select class='select' name='status-{{ line.id }}'>
{% for code in StockStatus.RECEIVING_CODES %}
<option value="{{ code }}" {% if code|add:"0" == line.status_code|add:"0" %}selected="selected"{% endif %}>{% stock_status_text code %}</option>
{% endfor %}
</select>
</div>
</td>
<td>
<div class='control-group'>
<select class='select' name='destination-{{ line.id }}'>
<option value="">----------</option>
{% for location in stock_locations %}
<option value="{{ location.pk }}" {% if location == line.get_destination %}selected="selected"{% endif %}>{{ location }}</option>
{% endfor %}
</select>
</div>
</td>
<td>
<button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'>
<span row='line_row_{{ line.id }}' class='fas fa-times-circle icon-red'></span>
</button>
</td>
</tr>
{% endfor %}
</table>
{% crispy form %}
<div id='form-errors'>{{ form_errors }}</div>
</form>
{% endblock %}

View File

@ -50,6 +50,9 @@ src="{% static 'img/blank_image.png' %}"
<button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'>
<span class='fas fa-print'></span>
</button>
<button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'>
<span class='fas fa-file-download'></span>
</button>
{% if roles.sales_order.change %}
<button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'>
<span class='fas fa-edit icon-green'></span>
@ -63,9 +66,11 @@ src="{% static 'img/blank_image.png' %}"
</button>
{% endif %}
{% endif %}
<!--
<button type='button' disabled='' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
<span class='fas fa-clipboard-list'></span>
</button>
-->
</div>
</div>
{% endblock %}
@ -196,4 +201,8 @@ $('#print-order-report').click(function() {
printSalesOrderReports([{{ order.pk }}]);
});
$('#export-order').click(function() {
exportOrder('{% url "so-export" order.id %}');
});
{% endblock %}

View File

@ -158,467 +158,38 @@
$("#so-lines-table").bootstrapTable("refresh");
}
$("#new-so-line").click(function() {
$("#new-so-line").click(function() {
constructForm('{% url "api-so-line-list" %}', {
fields: {
order: {
value: {{ order.pk }},
hidden: true,
},
part: {},
quantity: {},
reference: {},
sale_price: {},
sale_price_currency: {},
notes: {},
},
method: 'POST',
title: '{% trans "Add Line Item" %}',
onSuccess: reloadTable,
});
});
{% if order.status == SalesOrderStatus.PENDING %}
function showAllocationSubTable(index, row, element) {
// Construct a table showing stock items which have been allocated against this line item
var html = `<div class='sub-table'><table class='table table-striped table-condensed' id='allocation-table-${row.pk}'></table></div>`;
element.html(html);
var lineItem = row;
var table = $(`#allocation-table-${row.pk}`);
table.bootstrapTable({
data: row.allocations,
showHeader: false,
columns: [
{
width: '50%',
field: 'allocated',
title: '{% trans "Quantity" %}',
formatter: function(value, row, index, field) {
var text = '';
if (row.serial != null && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
return renderLink(text, `/stock/item/${row.item}/`);
},
},
{
field: 'location',
title: 'Location',
formatter: function(value, row, index, field) {
return renderLink(row.location_path, `/stock/location/${row.location}/`);
},
},
{
field: 'po'
},
{
field: 'buttons',
title: '{% trans "Actions" %}',
formatter: function(value, row, index, field) {
var html = "<div class='btn-group float-right' role='group'>";
var pk = row.pk;
{% if order.status == SalesOrderStatus.PENDING %}
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
{% endif %}
html += "</div>";
return html;
},
},
],
});
table.find(".button-allocation-edit").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/${pk}/edit/`, {
success: reloadTable,
});
});
table.find(".button-allocation-delete").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/${pk}/delete/`, {
success: reloadTable,
});
});
}
{% endif %}
function showFulfilledSubTable(index, row, element) {
// Construct a table showing stock items which have been fulfilled against this line item
var id = `fulfilled-table-${row.pk}`;
var html = `<div class='sub-table'><table class='table table-striped table-condensed' id='${id}'></table></div>`;
element.html(html);
var lineItem = row;
$(`#${id}`).bootstrapTable({
url: "{% url 'api-stock-list' %}",
queryParams: {
part: row.part,
sales_order: {{ order.id }},
},
showHeader: false,
columns: [
{
field: 'pk',
visible: false,
},
{
field: 'stock',
formatter: function(value, row) {
var text = '';
if (row.serial && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
return renderLink(text, `/stock/item/${row.pk}/`);
},
},
{
field: 'po'
},
],
});
}
$("#so-lines-table").inventreeTable({
formatNoMatches: function() { return "{% trans 'No matching line items' %}"; },
queryParams: {
order: {{ order.id }},
part_detail: true,
allocations: true,
},
sidePagination: 'server',
uniqueId: 'pk',
url: "{% url 'api-so-line-list' %}",
onPostBody: setupCallbacks,
{% if order.status == SalesOrderStatus.PENDING or order.status == SalesOrderStatus.SHIPPED %}
detailViewByClick: true,
detailView: true,
detailFilter: function(index, row) {
{% if order.status == SalesOrderStatus.PENDING %}
return row.allocated > 0;
{% else %}
return row.fulfilled > 0;
{% endif %}
},
{% if order.status == SalesOrderStatus.PENDING %}
detailFormatter: showAllocationSubTable,
{% else %}
detailFormatter: showFulfilledSubTable,
{% endif %}
{% endif %}
showFooter: true,
columns: [
{
field: 'pk',
title: '{% trans "ID" %}',
visible: false,
switchable: false,
},
{
sortable: true,
sortName: 'part__name',
field: 'part',
title: '{% trans "Part" %}',
formatter: function(value, row, index, field) {
if (row.part) {
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`);
} else {
return '-';
}
},
footerFormatter: function() {
return '{% trans "Total" %}'
},
},
{
sortable: true,
field: 'reference',
title: '{% trans "Reference" %}'
},
{
sortable: true,
field: 'quantity',
title: '{% trans "Quantity" %}',
footerFormatter: function(data) {
return data.map(function (row) {
return +row['quantity']
}).reduce(function (sum, i) {
return sum + i
}, 0)
},
},
{
sortable: true,
field: 'sale_price',
title: '{% trans "Unit Price" %}',
formatter: function(value, row) {
return row.sale_price_string || row.sale_price;
}
},
{
sortable: true,
title: '{% trans "Total price" %}',
formatter: function(value, row) {
var total = row.sale_price * row.quantity;
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.sale_price_currency});
return formatter.format(total)
},
footerFormatter: function(data) {
var total = data.map(function (row) {
return +row['sale_price']*row['quantity']
}).reduce(function (sum, i) {
return sum + i
}, 0)
var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD';
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
return formatter.format(total)
}
},
{
field: 'allocated',
{% if order.status == SalesOrderStatus.PENDING %}
title: '{% trans "Allocated" %}',
{% else %}
title: '{% trans "Fulfilled" %}',
{% endif %}
formatter: function(value, row, index, field) {
{% if order.status == SalesOrderStatus.PENDING %}
var quantity = row.allocated;
{% else %}
var quantity = row.fulfilled;
{% endif %}
return makeProgressBar(quantity, row.quantity, {
id: `order-line-progress-${row.pk}`,
});
},
sorter: function(valA, valB, rowA, rowB) {
{% if order.status == SalesOrderStatus.PENDING %}
var A = rowA.allocated;
var B = rowB.allocated;
{% else %}
var A = rowA.fulfilled;
var B = rowB.fulfilled;
{% endif %}
if (A == 0 && B == 0) {
return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
var progressA = parseFloat(A) / rowA.quantity;
var progressB = parseFloat(B) / rowB.quantity;
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'notes',
title: '{% trans "Notes" %}',
},
{
field: 'po',
title: '{% trans "PO" %}',
formatter: function(value, row, index, field) {
var po_name = "";
if (row.allocated) {
row.allocations.forEach(function(allocation) {
if (allocation.po != po_name) {
if (po_name) {
po_name = "-";
} else {
po_name = allocation.po
}
}
})
}
return `<div>` + po_name + `</div>`;
}
},
{% if order.status == SalesOrderStatus.PENDING %}
{
field: 'buttons',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group float-right' role='group'>`;
var pk = row.pk;
if (row.part) {
var part = row.part_detail;
if (part.trackable) {
html += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}');
}
html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}');
if (part.purchaseable) {
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}');
}
if (part.assembly) {
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}');
}
html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}');
}
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}');
html += `</div>`;
return html;
}
},
{% endif %}
],
});
function setupCallbacks() {
var table = $("#so-lines-table");
// Set up callbacks for the row buttons
table.find(".button-edit").click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/so-line/${pk}/`, {
constructForm('{% url "api-so-line-list" %}', {
fields: {
order: {
value: {{ order.pk }},
hidden: true,
},
part: {},
quantity: {},
reference: {},
sale_price: {},
sale_price_currency: {},
notes: {},
},
title: '{% trans "Edit Line Item" %}',
method: 'POST',
title: '{% trans "Add Line Item" %}',
onSuccess: reloadTable,
});
});
table.find(".button-delete").click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/so-line/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Line Item" %}',
onSuccess: reloadTable,
});
});
table.find(".button-add-by-sn").click(function() {
var pk = $(this).attr('pk');
inventreeGet(`/api/order/so-line/${pk}/`, {},
{
success: function(response) {
launchModalForm('{% url "so-assign-serials" %}', {
success: reloadTable,
data: {
line: pk,
part: response.part,
}
});
}
}
);
});
table.find(".button-add").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/new/`, {
success: reloadTable,
data: {
line: pk,
},
});
});
table.find(".button-build").click(function() {
var pk = $(this).attr('pk');
// Extract the row data from the table!
var idx = $(this).closest('tr').attr('data-index');
var row = table.bootstrapTable('getData')[idx];
var quantity = 1;
if (row.allocated < row.quantity) {
quantity = row.quantity - row.allocated;
loadSalesOrderLineItemTable(
'#so-lines-table',
{
order: {{ order.pk }},
status: {{ order.status }},
}
launchModalForm(`/build/new/`, {
follow: true,
data: {
part: pk,
sales_order: {{ order.id }},
quantity: quantity,
},
});
});
table.find(".button-buy").click(function() {
var pk = $(this).attr('pk');
launchModalForm("{% url 'order-parts' %}", {
data: {
parts: [pk],
},
});
});
$(".button-price").click(function() {
var pk = $(this).attr('pk');
var idx = $(this).closest('tr').attr('data-index');
var row = table.bootstrapTable('getData')[idx];
launchModalForm(
"{% url 'line-pricing' %}",
{
submit_text: '{% trans "Calculate price" %}',
data: {
line_item: pk,
quantity: row.quantity,
},
buttons: [{name: 'update_price',
title: '{% trans "Update Unit Price" %}'},],
success: reloadTable,
}
);
});
);
attachNavCallbacks({
name: 'sales-order',
default: 'order-items'
});
}
{% endblock %}

View File

@ -1,14 +0,0 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
<div class='alert alert-block alert-warning'>
{% trans "This action will unallocate the following stock from the Sales Order" %}:
<br>
<strong>
{% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }}
{% if allocation.item.location %} ({{ allocation.get_location }}){% endif %}
</strong>
</div>
{% endblock %}

View File

@ -251,7 +251,7 @@ class PurchaseOrderReceiveTest(OrderTest):
expected_code=400
).data
self.assertIn('Line items must be provided', str(data['items']))
self.assertIn('Line items must be provided', str(data))
# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())

View File

@ -10,7 +10,7 @@ from django.contrib.auth.models import Group
from InvenTree.status_codes import PurchaseOrderStatus
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import PurchaseOrder
import json
@ -103,86 +103,3 @@ class POTests(OrderViewTestCase):
# Test that the order was actually placed
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
class TestPOReceive(OrderViewTestCase):
""" Tests for receiving a purchase order """
def setUp(self):
super().setUp()
self.po = PurchaseOrder.objects.get(pk=1)
self.po.status = PurchaseOrderStatus.PLACED
self.po.save()
self.url = reverse('po-receive', args=(1,))
def post(self, data, validate=None):
response = self.client.post(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
if validate is not None:
data = json.loads(response.content)
if validate:
self.assertTrue(data['form_valid'])
else:
self.assertFalse(data['form_valid'])
return response
def test_get_dialog(self):
data = {
}
self.client.get(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
def test_receive_lines(self):
post_data = {
}
self.post(post_data, validate=False)
# Try with an invalid location
post_data['location'] = 12345
self.post(post_data, validate=False)
# Try with a valid location
post_data['location'] = 1
# Should fail due to invalid quantity
self.post(post_data, validate=False)
# Try to receive against an invalid line
post_data['line-800'] = 100
# Remove an invalid quantity of items
post_data['line-1'] = '7x5q'
self.post(post_data, validate=False)
# Receive negative number
post_data['line-1'] = -100
self.post(post_data, validate=False)
# Receive 75 items
post_data['line-1'] = 75
self.post(post_data, validate=True)
line = PurchaseOrderLineItem.objects.get(pk=1)
self.assertEqual(line.received, 75)
# Receive 30 more items
post_data['line-1'] = 30
self.post(post_data, validate=True)
line = PurchaseOrderLineItem.objects.get(pk=1)
self.assertEqual(line.received, 105)

View File

@ -13,7 +13,6 @@ purchase_order_detail_urls = [
url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
url(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'),
url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'),
url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'),
url(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'),
@ -37,6 +36,7 @@ purchase_order_urls = [
sales_order_detail_urls = [
url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'),
url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'),
url(r'^export/', views.SalesOrderExport.as_view(), name='so-export'),
url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'),
]
@ -44,12 +44,7 @@ sales_order_detail_urls = [
sales_order_urls = [
# URLs for sales order allocations
url(r'^allocation/', include([
url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'),
url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'),
url(r'(?P<pk>\d+)/', include([
url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'),
url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'),
])),
])),
# Display detail view for a single SalesOrder

View File

@ -23,13 +23,12 @@ from decimal import Decimal, InvalidOperation
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
from .admin import POLineItemResource
from .admin import POLineItemResource, SOLineItemResource
from build.models import Build
from company.models import Company, SupplierPart # ManufacturerPart
from stock.models import StockItem, StockLocation
from stock.models import StockItem
from part.models import Part
from common.models import InvenTreeSetting
from common.forms import UploadFileForm, MatchFieldForm
from common.views import FileManagementFormView
from common.files import FileManager
@ -37,12 +36,12 @@ from common.files import FileManager
from . import forms as order_forms
from part.views import PartPricing
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.views import AjaxView, AjaxUpdateView
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers import extract_serial_numbers
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import PurchaseOrderStatus, StockStatus
from InvenTree.status_codes import PurchaseOrderStatus
logger = logging.getLogger("inventree")
@ -437,6 +436,33 @@ class PurchaseOrderUpload(FileManagementFormView):
return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']}))
class SalesOrderExport(AjaxView):
"""
Export a sales order
- File format can optionally be passed as a query parameter e.g. ?format=CSV
- Default file format is CSV
"""
model = SalesOrder
role_required = 'sales_order.view'
def get(self, request, *args, **kwargs):
order = get_object_or_404(SalesOrder, pk=self.kwargs.get('pk', None))
export_format = request.GET.get('format', 'csv')
filename = f"{str(order)} - {order.customer.name}.{export_format}"
dataset = SOLineItemResource().export(queryset=order.lines.all())
filedata = dataset.export(format=export_format)
return DownloadFile(filedata, filename)
class PurchaseOrderExport(AjaxView):
""" File download for a purchase order
@ -451,7 +477,7 @@ class PurchaseOrderExport(AjaxView):
def get(self, request, *args, **kwargs):
order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
order = get_object_or_404(PurchaseOrder, pk=self.kwargs.get('pk', None))
export_format = request.GET.get('format', 'csv')
@ -468,202 +494,6 @@ class PurchaseOrderExport(AjaxView):
return DownloadFile(filedata, filename)
class PurchaseOrderReceive(AjaxUpdateView):
""" View for receiving parts which are outstanding against a PurchaseOrder.
Any parts which are outstanding are listed.
If all parts are marked as received, the order is closed out.
"""
form_class = order_forms.ReceivePurchaseOrderForm
ajax_form_title = _("Receive Parts")
ajax_template_name = "order/receive_parts.html"
# Specify role as we do not specify a Model against this view
role_required = 'purchase_order.change'
# Where the parts will be going (selected in POST request)
destination = None
def get_context_data(self):
ctx = {
'order': self.order,
'lines': self.lines,
'stock_locations': StockLocation.objects.all(),
}
return ctx
def get_lines(self):
"""
Extract particular line items from the request,
or default to *all* pending line items if none are provided
"""
lines = None
if 'line' in self.request.GET:
line_id = self.request.GET.get('line')
try:
lines = PurchaseOrderLineItem.objects.filter(pk=line_id)
except (PurchaseOrderLineItem.DoesNotExist, ValueError):
pass
# TODO - Option to pass multiple lines?
# No lines specified - default selection
if lines is None:
lines = self.order.pending_line_items()
return lines
def get(self, request, *args, **kwargs):
""" Respond to a GET request. Determines which parts are outstanding,
and presents a list of these parts to the user.
"""
self.request = request
self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
self.lines = self.get_lines()
for line in self.lines:
# Pre-fill the remaining quantity
line.receive_quantity = line.remaining()
return self.renderJsonResponse(request, form=self.get_form())
def post(self, request, *args, **kwargs):
""" Respond to a POST request. Data checking and error handling.
If the request is valid, new StockItem objects will be made
for each received item.
"""
self.request = request
self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
errors = False
self.lines = []
self.destination = None
msg = _("Items received")
# Extract the destination for received parts
if 'location' in request.POST:
pk = request.POST['location']
try:
self.destination = StockLocation.objects.get(id=pk)
except (StockLocation.DoesNotExist, ValueError):
pass
# Extract information on all submitted line items
for item in request.POST:
if item.startswith('line-'):
pk = item.replace('line-', '')
try:
line = PurchaseOrderLineItem.objects.get(id=pk)
except (PurchaseOrderLineItem.DoesNotExist, ValueError):
continue
# Check that the StockStatus was set
status_key = 'status-{pk}'.format(pk=pk)
status = request.POST.get(status_key, StockStatus.OK)
try:
status = int(status)
except ValueError:
status = StockStatus.OK
if status in StockStatus.RECEIVING_CODES:
line.status_code = status
else:
line.status_code = StockStatus.OK
# Check the destination field
line.destination = None
if self.destination:
# If global destination is set, overwrite line value
line.destination = self.destination
else:
destination_key = f'destination-{pk}'
destination = request.POST.get(destination_key, None)
if destination:
try:
line.destination = StockLocation.objects.get(pk=destination)
except (StockLocation.DoesNotExist, ValueError):
pass
# Check that line matches the order
if not line.order == self.order:
# TODO - Display a non-field error?
continue
# Ignore a part that doesn't map to a SupplierPart
try:
if line.part is None:
continue
except SupplierPart.DoesNotExist:
continue
receive = self.request.POST[item]
try:
receive = Decimal(receive)
except InvalidOperation:
# In the case on an invalid input, reset to default
receive = line.remaining()
msg = _("Error converting quantity to number")
errors = True
if receive < 0:
receive = 0
errors = True
msg = _("Receive quantity less than zero")
line.receive_quantity = receive
self.lines.append(line)
if len(self.lines) == 0:
msg = _("No lines specified")
errors = True
# No errors? Receive the submitted parts!
if errors is False:
self.receive_parts()
data = {
'form_valid': errors is False,
'success': msg,
}
return self.renderJsonResponse(request, data=data, form=self.get_form())
@transaction.atomic
def receive_parts(self):
""" Called once the form has been validated.
Create new stockitems against received parts.
"""
for line in self.lines:
if not line.part:
continue
self.order.receive_line_item(
line,
line.destination,
line.receive_quantity,
self.request.user,
status=line.status_code,
purchase_price=line.purchase_price,
)
class OrderParts(AjaxView):
""" View for adding various SupplierPart items to a Purchase Order.
@ -1172,105 +1002,6 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
)
class SalesOrderAllocationCreate(AjaxCreateView):
""" View for creating a new SalesOrderAllocation """
model = SalesOrderAllocation
form_class = order_forms.CreateSalesOrderAllocationForm
ajax_form_title = _('Allocate Stock to Order')
def get_initial(self):
initials = super().get_initial().copy()
line_id = self.request.GET.get('line', None)
if line_id is not None:
line = SalesOrderLineItem.objects.get(pk=line_id)
initials['line'] = line
# Search for matching stock items, pre-fill if there is only one
items = StockItem.objects.filter(part=line.part)
quantity = line.quantity - line.allocated_quantity()
if quantity < 0:
quantity = 0
if items.count() == 1:
item = items.first()
initials['item'] = item
# Reduce the quantity IF there is not enough stock
qmax = item.quantity - item.allocation_count()
if qmax < quantity:
quantity = qmax
initials['quantity'] = quantity
return initials
def get_form(self):
form = super().get_form()
line_id = form['line'].value()
# If a line item has been specified, reduce the queryset for the stockitem accordingly
try:
line = SalesOrderLineItem.objects.get(pk=line_id)
# Construct a queryset for allowable stock items
queryset = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
# Ensure the part reference matches
queryset = queryset.filter(part=line.part)
# Exclude StockItem which are already allocated to this order
allocated = [allocation.item.pk for allocation in line.allocations.all()]
queryset = queryset.exclude(pk__in=allocated)
# Exclude stock items which have expired
if not InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_SALE'):
queryset = queryset.exclude(StockItem.EXPIRED_FILTER)
form.fields['item'].queryset = queryset
# Hide the 'line' field
form.fields['line'].widget = HiddenInput()
except (ValueError, SalesOrderLineItem.DoesNotExist):
pass
return form
class SalesOrderAllocationEdit(AjaxUpdateView):
model = SalesOrderAllocation
form_class = order_forms.EditSalesOrderAllocationForm
ajax_form_title = _('Edit Allocation Quantity')
def get_form(self):
form = super().get_form()
# Prevent the user from editing particular fields
form.fields.pop('item')
form.fields.pop('line')
return form
class SalesOrderAllocationDelete(AjaxDeleteView):
model = SalesOrderAllocation
ajax_form_title = _("Remove allocation")
context_object_name = 'allocation'
ajax_template_name = "order/so_allocation_delete.html"
class LineItemPricing(PartPricing):
""" View for inspecting part pricing information """

View File

@ -1100,6 +1100,12 @@ class BomList(generics.ListCreateAPIView):
except AttributeError:
pass
try:
# Include or exclude pricing information in the serialized data
kwargs['include_pricing'] = str2bool(self.request.GET.get('include_pricing', True))
except AttributeError:
pass
# Ensure the request context is passed through!
kwargs['context'] = self.get_serializer_context()
@ -1141,6 +1147,18 @@ class BomList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist):
pass
include_pricing = str2bool(params.get('include_pricing', True))
if include_pricing:
queryset = self.annotate_pricing(queryset)
return queryset
def annotate_pricing(self, queryset):
"""
Add part pricing information to the queryset
"""
# Annotate with purchase prices
queryset = queryset.annotate(
purchase_price_min=Min('sub_part__stock_items__purchase_price'),

View File

@ -1,13 +1,9 @@
from __future__ import unicode_literals
import os
import logging
from django.db.utils import OperationalError, ProgrammingError
from django.apps import AppConfig
from django.conf import settings
from PIL import UnidentifiedImageError
from InvenTree.ready import canAppAccessDatabase
@ -24,40 +20,8 @@ class PartConfig(AppConfig):
"""
if canAppAccessDatabase():
self.generate_part_thumbnails()
self.update_trackable_status()
def generate_part_thumbnails(self):
"""
Generate thumbnail images for any Part that does not have one.
This function exists mainly for legacy support,
as any *new* image uploaded will have a thumbnail generated automatically.
"""
from .models import Part
logger.debug("InvenTree: Checking Part image thumbnails")
try:
# Only check parts which have images
for part in Part.objects.exclude(image=None):
if part.image:
url = part.image.thumbnail.name
loc = os.path.join(settings.MEDIA_ROOT, url)
if not os.path.exists(loc):
logger.info("InvenTree: Generating thumbnail for Part '{p}'".format(p=part.name))
try:
part.image.render_variations(replace=False)
except FileNotFoundError:
logger.warning(f"Image file '{part.image}' missing")
pass
except UnidentifiedImageError:
logger.warning(f"Image file '{part.image}' is invalid")
except (OperationalError, ProgrammingError):
# Exception if the database has not been migrated yet
pass
def update_trackable_status(self):
"""
Check for any instances where a trackable part is used in the BOM
@ -72,7 +36,7 @@ class PartConfig(AppConfig):
items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True)
for item in items:
print(f"Marking part '{item.part.name}' as trackable")
logger.info(f"Marking part '{item.part.name}' as trackable")
item.part.trackable = True
item.part.clean()
item.part.save()

View File

@ -189,12 +189,15 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# Process manufacturer part
for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts):
if manufacturer_part:
if manufacturer_part and manufacturer_part.manufacturer:
manufacturer_name = manufacturer_part.manufacturer.name
else:
manufacturer_name = ''
manufacturer_mpn = manufacturer_part.MPN
if manufacturer_part:
manufacturer_mpn = manufacturer_part.MPN
else:
manufacturer_mpn = ''
# Generate column names for this manufacturer
k_man = manufacturer_headers[0] + "_" + str(manufacturer_idx)
@ -210,12 +213,15 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# Process supplier parts
for supplier_idx, supplier_part in enumerate(manufacturer_part.supplier_parts.all()):
if supplier_part.supplier:
if supplier_part.supplier and supplier_part.supplier:
supplier_name = supplier_part.supplier.name
else:
supplier_name = ''
supplier_sku = supplier_part.SKU
if supplier_part:
supplier_sku = supplier_part.SKU
else:
supplier_sku = ''
# Generate column names for this supplier
k_sup = str(supplier_headers[0]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx)

View File

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

View File

@ -4,6 +4,7 @@ Part database model definitions
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import decimal
import os
import logging
@ -1530,10 +1531,13 @@ class Part(MPTTModel):
for item in self.get_bom_items().all().select_related('sub_part'):
if item.sub_part.pk == self.pk:
print("Warning: Item contains itself in BOM")
logger.warning(f"WARNING: BomItem ID {item.pk} contains itself in BOM")
continue
prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal, purchase=purchase)
q = decimal.Decimal(quantity)
i = decimal.Decimal(item.quantity)
prices = item.sub_part.get_price_range(q * i, internal=internal, purchase=purchase)
if prices is None:
continue
@ -2329,6 +2333,23 @@ class BomItem(models.Model):
def get_api_url():
return reverse('api-bom-list')
def get_stock_filter(self):
"""
Return a queryset filter for selecting StockItems which match this BomItem
- If allow_variants is True, allow all part variants
"""
# Target part
part = self.sub_part
if self.allow_variants:
variants = part.get_descendants(include_self=True)
return Q(part__in=[v.pk for v in variants])
else:
return Q(part=part)
def save(self, *args, **kwargs):
self.clean()

View File

@ -193,6 +193,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
fields = [
'pk',
'IPN',
'default_location',
'name',
'revision',
'full_name',
@ -418,6 +419,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
part_detail = kwargs.pop('part_detail', False)
sub_part_detail = kwargs.pop('sub_part_detail', False)
include_pricing = kwargs.pop('include_pricing', False)
super(BomItemSerializer, self).__init__(*args, **kwargs)
@ -427,6 +429,14 @@ class BomItemSerializer(InvenTreeModelSerializer):
if sub_part_detail is not True:
self.fields.pop('sub_part_detail')
if not include_pricing:
# Remove all pricing related fields
self.fields.pop('price_range')
self.fields.pop('purchase_price_min')
self.fields.pop('purchase_price_max')
self.fields.pop('purchase_price_avg')
self.fields.pop('purchase_price_range')
@staticmethod
def setup_eager_loading(queryset):
queryset = queryset.prefetch_related('part')

View File

@ -328,6 +328,12 @@
// If image / thumbnail data present, live update
if (data.image) {
$('#part-image').attr('src', data.image);
// Reset the "modal image" view
$('#part-thumb').click(function() {
showModalImage(data.image);
});
} else {
// Otherwise, reload the page
location.reload();
@ -372,7 +378,7 @@
{
success: function(items) {
adjustStock(action, items, {
onSuccess: function() {
success: function() {
location.reload();
}
});

View File

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

View File

@ -120,7 +120,13 @@ class BomItemTest(TestCase):
def test_pricing(self):
self.bob.get_price(1)
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(84.5), Decimal(89.5)))
self.assertEqual(
self.bob.get_bom_price_range(1, internal=True),
(Decimal(29.5), Decimal(89.5))
)
# remove internal price for R_2K2_0805
self.r1.internal_price_breaks.delete()
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(82.5), Decimal(87.5)))
self.assertEqual(
self.bob.get_bom_price_range(1, internal=True),
(Decimal(27.5), Decimal(87.5))
)

View File

@ -2,15 +2,20 @@
JSON API for the Stock app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from datetime import datetime, timedelta
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include
from django.urls import reverse
from django.http import JsonResponse
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from rest_framework import status
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import generics, filters, permissions
@ -22,33 +27,26 @@ from .models import StockItemTracking
from .models import StockItemAttachment
from .models import StockItemTestResult
from part.models import Part, PartCategory
from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer
from company.models import Company, SupplierPart
from company.serializers import CompanySerializer, SupplierPartSerializer
from order.models import PurchaseOrder
from order.models import SalesOrder, SalesOrderAllocation
from order.serializers import POSerializer
import common.settings
import common.models
from .serializers import StockItemSerializer
from .serializers import LocationSerializer, LocationBriefSerializer
from .serializers import StockTrackingSerializer
from .serializers import StockItemAttachmentSerializer
from .serializers import StockItemTestResultSerializer
import stock.serializers as StockSerializers
from InvenTree.views import TreeSerializer
from InvenTree.helpers import str2bool, isNull
from InvenTree.api import AttachmentMixin
from InvenTree.filters import InvenTreeOrderingFilter
from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta
class StockCategoryTree(TreeSerializer):
title = _('Stock')
@ -80,12 +78,12 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
"""
queryset = StockItem.objects.all()
serializer_class = StockItemSerializer
serializer_class = StockSerializers.StockItemSerializer
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = StockItemSerializer.annotate_queryset(queryset)
queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
return queryset
@ -121,7 +119,7 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
instance.mark_for_deletion()
class StockAdjust(APIView):
class StockAdjustView(generics.CreateAPIView):
"""
A generic class for handling stocktake actions.
@ -135,184 +133,57 @@ class StockAdjust(APIView):
queryset = StockItem.objects.none()
allow_missing_quantity = False
def get_serializer_context(self):
context = super().get_serializer_context()
def get_items(self, request):
"""
Return a list of items posted to the endpoint.
Will raise validation errors if the items are not
correctly formatted.
"""
context['request'] = self.request
_items = []
if 'item' in request.data:
_items = [request.data['item']]
elif 'items' in request.data:
_items = request.data['items']
else:
_items = []
if len(_items) == 0:
raise ValidationError(_('Request must contain list of stock items'))
# List of validated items
self.items = []
for entry in _items:
if not type(entry) == dict:
raise ValidationError(_('Improperly formatted data'))
# Look for a 'pk' value (use 'id' as a backup)
pk = entry.get('pk', entry.get('id', None))
try:
pk = int(pk)
except (ValueError, TypeError):
raise ValidationError(_('Each entry must contain a valid integer primary-key'))
try:
item = StockItem.objects.get(pk=pk)
except (StockItem.DoesNotExist):
raise ValidationError({
pk: [_('Primary key does not match valid stock item')]
})
if self.allow_missing_quantity and 'quantity' not in entry:
entry['quantity'] = item.quantity
try:
quantity = Decimal(str(entry.get('quantity', None)))
except (ValueError, TypeError, InvalidOperation):
raise ValidationError({
pk: [_('Invalid quantity value')]
})
if quantity < 0:
raise ValidationError({
pk: [_('Quantity must not be less than zero')]
})
self.items.append({
'item': item,
'quantity': quantity
})
# Extract 'notes' field
self.notes = str(request.data.get('notes', ''))
return context
class StockCount(StockAdjust):
class StockCount(StockAdjustView):
"""
Endpoint for counting stock (performing a stocktake).
"""
def post(self, request, *args, **kwargs):
self.get_items(request)
n = 0
for item in self.items:
if item['item'].stocktake(item['quantity'], request.user, notes=self.notes):
n += 1
return Response({'success': _('Updated stock for {n} items').format(n=n)})
serializer_class = StockSerializers.StockCountSerializer
class StockAdd(StockAdjust):
class StockAdd(StockAdjustView):
"""
Endpoint for adding a quantity of stock to an existing StockItem
"""
def post(self, request, *args, **kwargs):
self.get_items(request)
n = 0
for item in self.items:
if item['item'].add_stock(item['quantity'], request.user, notes=self.notes):
n += 1
return Response({"success": "Added stock for {n} items".format(n=n)})
serializer_class = StockSerializers.StockAddSerializer
class StockRemove(StockAdjust):
class StockRemove(StockAdjustView):
"""
Endpoint for removing a quantity of stock from an existing StockItem.
"""
def post(self, request, *args, **kwargs):
self.get_items(request)
n = 0
for item in self.items:
if item['quantity'] > item['item'].quantity:
raise ValidationError({
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
})
if item['item'].take_stock(item['quantity'], request.user, notes=self.notes):
n += 1
return Response({"success": "Removed stock for {n} items".format(n=n)})
serializer_class = StockSerializers.StockRemoveSerializer
class StockTransfer(StockAdjust):
class StockTransfer(StockAdjustView):
"""
API endpoint for performing stock movements
"""
allow_missing_quantity = True
def post(self, request, *args, **kwargs):
data = request.data
try:
location = StockLocation.objects.get(pk=data.get('location', None))
except (ValueError, StockLocation.DoesNotExist):
raise ValidationError({'location': [_('Valid location must be specified')]})
n = 0
self.get_items(request)
for item in self.items:
if item['quantity'] > item['item'].quantity:
raise ValidationError({
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
})
# If quantity is not specified, move the entire stock
if item['quantity'] in [0, None]:
item['quantity'] = item['item'].quantity
if item['item'].move(location, self.notes, request.user, quantity=item['quantity']):
n += 1
return Response({'success': _('Moved {n} parts to {loc}').format(
n=n,
loc=str(location),
)})
serializer_class = StockSerializers.StockTransferSerializer
class StockLocationList(generics.ListCreateAPIView):
""" API endpoint for list view of StockLocation objects:
"""
API endpoint for list view of StockLocation objects:
- GET: Return list of StockLocation objects
- POST: Create a new StockLocation
"""
queryset = StockLocation.objects.all()
serializer_class = LocationSerializer
serializer_class = StockSerializers.LocationSerializer
def filter_queryset(self, queryset):
"""
@ -514,7 +385,7 @@ class StockList(generics.ListCreateAPIView):
- POST: Create a new StockItem
"""
serializer_class = StockItemSerializer
serializer_class = StockSerializers.StockItemSerializer
queryset = StockItem.objects.all()
filterset_class = StockFilter
@ -636,7 +507,7 @@ class StockList(generics.ListCreateAPIView):
# Serialize each StockLocation object
for location in locations:
location_map[location.pk] = LocationBriefSerializer(location).data
location_map[location.pk] = StockSerializers.LocationBriefSerializer(location).data
# Now update each StockItem with the related StockLocation data
for stock_item in data:
@ -662,7 +533,7 @@ class StockList(generics.ListCreateAPIView):
queryset = super().get_queryset(*args, **kwargs)
queryset = StockItemSerializer.annotate_queryset(queryset)
queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
# Do not expose StockItem objects which are scheduled for deletion
queryset = queryset.filter(scheduled_for_deletion=False)
@ -670,14 +541,14 @@ class StockList(generics.ListCreateAPIView):
return queryset
def filter_queryset(self, queryset):
"""
Custom filtering for the StockItem queryset
"""
params = self.request.query_params
queryset = super().filter_queryset(queryset)
# Perform basic filtering:
# Note: We do not let DRF filter here, it be slow AF
supplier_part = params.get('supplier_part', None)
if supplier_part:
@ -775,6 +646,31 @@ class StockList(generics.ListCreateAPIView):
# Filter StockItem without build allocations or sales order allocations
queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True))
# Exclude StockItems which are already allocated to a particular SalesOrder
exclude_so_allocation = params.get('exclude_so_allocation', None)
if exclude_so_allocation is not None:
try:
order = SalesOrder.objects.get(pk=exclude_so_allocation)
# Grab all the active SalesOrderAllocations for this order
allocations = SalesOrderAllocation.objects.filter(
line__pk__in=[
line.pk for line in order.lines.all()
]
)
# Exclude any stock item which is already allocated to the sales order
queryset = queryset.exclude(
pk__in=[
a.item.pk for a in allocations
]
)
except (ValueError, SalesOrder.DoesNotExist):
pass
# Does the client wish to filter by the Part ID?
part_id = params.get('part', None)
@ -818,7 +714,7 @@ class StockList(generics.ListCreateAPIView):
if loc_id is not None:
# Filter by 'null' location (i.e. top-level items)
if isNull(loc_id):
if isNull(loc_id) and not cascade:
queryset = queryset.filter(location=None)
else:
try:
@ -843,6 +739,18 @@ class StockList(generics.ListCreateAPIView):
except (ValueError, PartCategory.DoesNotExist):
raise ValidationError({"category": "Invalid category id specified"})
# Does the client wish to filter by BomItem
bom_item_id = params.get('bom_item', None)
if bom_item_id is not None:
try:
bom_item = BomItem.objects.get(pk=bom_item_id)
queryset = queryset.filter(bom_item.get_stock_filter())
except (ValueError, BomItem.DoesNotExist):
pass
# Filter by StockItem status
status = params.get('status', None)
@ -939,7 +847,7 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
"""
queryset = StockItemAttachment.objects.all()
serializer_class = StockItemAttachmentSerializer
serializer_class = StockSerializers.StockItemAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
@ -958,7 +866,7 @@ class StockAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix
"""
queryset = StockItemAttachment.objects.all()
serializer_class = StockItemAttachmentSerializer
serializer_class = StockSerializers.StockItemAttachmentSerializer
class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView):
@ -967,7 +875,7 @@ class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView):
"""
queryset = StockItemTestResult.objects.all()
serializer_class = StockItemTestResultSerializer
serializer_class = StockSerializers.StockItemTestResultSerializer
class StockItemTestResultList(generics.ListCreateAPIView):
@ -976,7 +884,7 @@ class StockItemTestResultList(generics.ListCreateAPIView):
"""
queryset = StockItemTestResult.objects.all()
serializer_class = StockItemTestResultSerializer
serializer_class = StockSerializers.StockItemTestResultSerializer
filter_backends = [
DjangoFilterBackend,
@ -1024,7 +932,7 @@ class StockTrackingDetail(generics.RetrieveAPIView):
"""
queryset = StockItemTracking.objects.all()
serializer_class = StockTrackingSerializer
serializer_class = StockSerializers.StockTrackingSerializer
class StockTrackingList(generics.ListAPIView):
@ -1037,7 +945,7 @@ class StockTrackingList(generics.ListAPIView):
"""
queryset = StockItemTracking.objects.all()
serializer_class = StockTrackingSerializer
serializer_class = StockSerializers.StockTrackingSerializer
def get_serializer(self, *args, **kwargs):
try:
@ -1073,7 +981,7 @@ class StockTrackingList(generics.ListAPIView):
if 'location' in deltas:
try:
location = StockLocation.objects.get(pk=deltas['location'])
serializer = LocationSerializer(location)
serializer = StockSerializers.LocationSerializer(location)
deltas['location_detail'] = serializer.data
except:
pass
@ -1082,7 +990,7 @@ class StockTrackingList(generics.ListAPIView):
if 'stockitem' in deltas:
try:
stockitem = StockItem.objects.get(pk=deltas['stockitem'])
serializer = StockItemSerializer(stockitem)
serializer = StockSerializers.StockItemSerializer(stockitem)
deltas['stockitem_detail'] = serializer.data
except:
pass
@ -1164,7 +1072,7 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
"""
queryset = StockLocation.objects.all()
serializer_class = LocationSerializer
serializer_class = StockSerializers.LocationSerializer
stock_api_urls = [

View File

@ -2,27 +2,29 @@
JSON serializers for Stock app
"""
from rest_framework import serializers
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from decimal import Decimal
from datetime import datetime, timedelta
from django.db import transaction
from django.utils.translation import ugettext_lazy as _
from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value
from django.db.models import BooleanField
from django.db.models import Q
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubquerySum, SubqueryCount
from .models import StockItem, StockLocation
from .models import StockItemTracking
from .models import StockItemAttachment
from .models import StockItemTestResult
from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value
from django.db.models import BooleanField
from django.db.models import Q
from sql_util.utils import SubquerySum, SubqueryCount
from decimal import Decimal
from datetime import datetime, timedelta
import common.models
from common.settings import currency_code_default, currency_code_mappings
@ -64,6 +66,7 @@ class StockItemSerializerBrief(InvenTreeModelSerializer):
'location',
'location_name',
'quantity',
'serial',
]
@ -395,3 +398,196 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
'label',
'tracking_type',
]
class StockAdjustmentItemSerializer(serializers.Serializer):
"""
Serializer for a single StockItem within a stock adjument request.
Fields:
- item: StockItem object
- quantity: Numerical quantity
"""
class Meta:
fields = [
'item',
'quantity'
]
pk = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label='stock_item',
help_text=_('StockItem primary key value')
)
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
required=True
)
class StockAdjustmentSerializer(serializers.Serializer):
"""
Base class for managing stock adjustment actions via the API
"""
class Meta:
fields = [
'items',
'notes',
]
items = StockAdjustmentItemSerializer(many=True)
notes = serializers.CharField(
required=False,
allow_blank=True,
label=_("Notes"),
help_text=_("Stock transaction notes"),
)
def validate(self, data):
super().validate(data)
items = data.get('items', [])
if len(items) == 0:
raise ValidationError(_("A list of stock items must be provided"))
return data
class StockCountSerializer(StockAdjustmentSerializer):
"""
Serializer for counting stock items
"""
def save(self):
request = self.context['request']
data = self.validated_data
items = data['items']
notes = data.get('notes', '')
with transaction.atomic():
for item in items:
stock_item = item['pk']
quantity = item['quantity']
stock_item.stocktake(
quantity,
request.user,
notes=notes
)
class StockAddSerializer(StockAdjustmentSerializer):
"""
Serializer for adding stock to stock item(s)
"""
def save(self):
request = self.context['request']
data = self.validated_data
notes = data.get('notes', '')
with transaction.atomic():
for item in data['items']:
stock_item = item['pk']
quantity = item['quantity']
stock_item.add_stock(
quantity,
request.user,
notes=notes
)
class StockRemoveSerializer(StockAdjustmentSerializer):
"""
Serializer for removing stock from stock item(s)
"""
def save(self):
request = self.context['request']
data = self.validated_data
notes = data.get('notes', '')
with transaction.atomic():
for item in data['items']:
stock_item = item['pk']
quantity = item['quantity']
stock_item.take_stock(
quantity,
request.user,
notes=notes
)
class StockTransferSerializer(StockAdjustmentSerializer):
"""
Serializer for transferring (moving) stock item(s)
"""
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Destination stock location'),
)
class Meta:
fields = [
'items',
'notes',
'location',
]
def validate(self, data):
super().validate(data)
# TODO: Any specific validation of location field?
return data
def save(self):
request = self.context['request']
data = self.validated_data
items = data['items']
notes = data.get('notes', '')
location = data['location']
with transaction.atomic():
for item in items:
stock_item = item['pk']
quantity = item['quantity']
stock_item.move(
location,
notes,
request.user,
quantity=quantity
)

View File

@ -561,7 +561,7 @@ function itemAdjust(action) {
{
success: function(item) {
adjustStock(action, [item], {
onSuccess: function() {
success: function() {
location.reload();
}
});

View File

@ -287,7 +287,7 @@
{
success: function(items) {
adjustStock(action, items, {
onSuccess: function() {
success: function() {
location.reload();
}
});

View File

@ -513,31 +513,34 @@ class StocktakeTest(StockAPITestCase):
# POST with a valid action
response = self.post(url, data)
self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST)
self.assertIn("This field is required", str(response.data["items"]))
data['items'] = [{
'no': 'aa'
}]
# POST without a PK
response = self.post(url, data)
self.assertContains(response, 'must contain a valid integer primary-key', status_code=status.HTTP_400_BAD_REQUEST)
response = self.post(url, data, expected_code=400)
self.assertIn('This field is required', str(response.data))
# POST with an invalid PK
data['items'] = [{
'pk': 10
}]
response = self.post(url, data)
self.assertContains(response, 'does not match valid stock item', status_code=status.HTTP_400_BAD_REQUEST)
response = self.post(url, data, expected_code=400)
self.assertContains(response, 'object does not exist', status_code=status.HTTP_400_BAD_REQUEST)
# POST with missing quantity value
data['items'] = [{
'pk': 1234
}]
response = self.post(url, data)
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
response = self.post(url, data, expected_code=400)
self.assertContains(response, 'This field is required', status_code=status.HTTP_400_BAD_REQUEST)
# POST with an invalid quantity value
data['items'] = [{
@ -546,7 +549,7 @@ class StocktakeTest(StockAPITestCase):
}]
response = self.post(url, data)
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
self.assertContains(response, 'A valid number is required', status_code=status.HTTP_400_BAD_REQUEST)
data['items'] = [{
'pk': 1234,
@ -554,18 +557,7 @@ class StocktakeTest(StockAPITestCase):
}]
response = self.post(url, data)
self.assertContains(response, 'must not be less than zero', status_code=status.HTTP_400_BAD_REQUEST)
# Test with a single item
data = {
'item': {
'pk': 1234,
'quantity': '10',
}
}
response = self.post(url, data)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertContains(response, 'Ensure this value is greater than or equal to 0', status_code=status.HTTP_400_BAD_REQUEST)
def test_transfer(self):
"""
@ -573,24 +565,27 @@ class StocktakeTest(StockAPITestCase):
"""
data = {
'item': {
'pk': 1234,
'quantity': 10,
},
'items': [
{
'pk': 1234,
'quantity': 10,
}
],
'location': 1,
'notes': "Moving to a new location"
}
url = reverse('api-stock-transfer')
response = self.post(url, data)
self.assertContains(response, "Moved 1 parts to", status_code=status.HTTP_200_OK)
# This should succeed
response = self.post(url, data, expected_code=201)
# Now try one which will fail due to a bad location
data['location'] = 'not a location'
response = self.post(url, data)
self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST)
response = self.post(url, data, expected_code=400)
self.assertContains(response, 'Incorrect type. Expected pk value', status_code=status.HTTP_400_BAD_REQUEST)
class StockItemDeletionTest(StockAPITestCase):

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
from django.core.exceptions import ValidationError
from django.views.generic.edit import FormMixin
from django.views.generic import DetailView, ListView, UpdateView
from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict
from django.forms import HiddenInput
from django.urls import reverse
@ -145,29 +145,6 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
return super().get(request, *args, **kwargs)
class StockItemNotes(InvenTreeRoleMixin, UpdateView):
""" View for editing the 'notes' field of a StockItem object """
context_object_name = 'item'
template_name = 'stock/item_notes.html'
model = StockItem
role_required = 'stock.view'
fields = ['notes']
def get_success_url(self):
return reverse('stock-item-notes', kwargs={'pk': self.get_object().id})
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['editing'] = str2bool(self.request.GET.get('edit', ''))
return ctx
class StockLocationEdit(AjaxUpdateView):
"""
View for editing details of a StockLocation.

View File

@ -43,6 +43,12 @@
</a>
</li>
<li class='list-group-item' title='{% trans "Forms" %}'>
<a href='#' class='nav-toggle' id='select-user-forms'>
<span class='fas fa-table'></span>{% trans "Forms" %}
</a>
</li>
<!--
<li class='list-group-item' title='{% trans "Settings" %}'>
<a href='#' class='nav-toggle' id='select-user-settings'>

View File

@ -17,8 +17,8 @@
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_BOM" icon="fa-dollar-sign" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}
{% include "InvenTree/settings/setting.html" with key="PART_CREATE_INITIAL" icon="fa-boxes" %}
<tr><td colspan='5'></td></tr>

View File

@ -21,6 +21,7 @@
{% include "InvenTree/settings/user_search.html" %}
{% include "InvenTree/settings/user_labels.html" %}
{% include "InvenTree/settings/user_reports.html" %}
{% include "InvenTree/settings/user_forms.html" %}
{% if user.is_staff %}

View File

@ -0,0 +1,22 @@
{% extends "panel.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block label %}user-forms{% endblock %}
{% block heading %}
{% trans "Form Settings" %}
{% endblock %}
{% block content %}
<div class='row'>
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -10,6 +10,7 @@
/* exported
attachClipboard,
enableDragAndDrop,
exportFormatOptions,
inventreeDocReady,
inventreeLoad,
inventreeSave,
@ -46,6 +47,31 @@ function attachClipboard(selector, containerselector, textElement) {
}
/**
* Return a standard list of export format options *
*/
function exportFormatOptions() {
return [
{
value: 'csv',
display_name: 'CSV',
},
{
value: 'tsv',
display_name: 'TSV',
},
{
value: 'xls',
display_name: 'XLS',
},
{
value: 'xlsx',
display_name: 'XLSX',
},
];
}
function inventreeDocReady() {
/* Run this function when the HTML document is loaded.
* This will be called for every page that extends "base.html"

View File

@ -148,6 +148,13 @@ function loadBomTable(table, options) {
ordering: 'name',
};
// Do we show part pricing in the BOM table?
var show_pricing = global_settings.PART_SHOW_PRICE_IN_BOM;
if (!show_pricing) {
params.include_pricing = false;
}
if (options.part_detail) {
params.part_detail = true;
}
@ -282,32 +289,34 @@ function loadBomTable(table, options) {
}
});
cols.push({
field: 'purchase_price_range',
title: '{% trans "Purchase Price Range" %}',
searchable: false,
sortable: true,
});
if (show_pricing) {
cols.push({
field: 'purchase_price_range',
title: '{% trans "Purchase Price Range" %}',
searchable: false,
sortable: true,
});
cols.push({
field: 'purchase_price_avg',
title: '{% trans "Purchase Price Average" %}',
searchable: false,
sortable: true,
});
cols.push({
field: 'purchase_price_avg',
title: '{% trans "Purchase Price Average" %}',
searchable: false,
sortable: true,
});
cols.push({
field: 'price_range',
title: '{% trans "Supplier Cost" %}',
sortable: true,
formatter: function(value) {
if (value) {
return value;
} else {
return `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
cols.push({
field: 'price_range',
title: '{% trans "Supplier Cost" %}',
sortable: true,
formatter: function(value) {
if (value) {
return value;
} else {
return `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
}
}
}
});
});
}
cols.push({
field: 'optional',

View File

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

View File

@ -273,6 +273,11 @@ function setupFilterList(tableKey, table, target) {
var element = $(target);
if (!element) {
console.log(`WARNING: setupFilterList could not find target '${target}'`);
return;
}
// One blank slate, please
element.empty();

View File

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

View File

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

View File

@ -37,7 +37,7 @@ function renderCompany(name, data, parameters, options) {
html += `<span><b>${data.name}</b></span> - <i>${data.description}</i>`;
html += `<span class='float-right'>{% trans "Company ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Company ID" %}: ${data.pk}</small></span>`;
return html;
}
@ -47,22 +47,59 @@ function renderCompany(name, data, parameters, options) {
// eslint-disable-next-line no-unused-vars
function renderStockItem(name, data, parameters, options) {
var image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
var html = `<img src='${image}' class='select2-thumbnail'>`;
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
if (data.serial && data.quantity == 1) {
html += ` - <i>{% trans "Serial Number" %}: ${data.serial}`;
} else {
html += ` - <i>{% trans "Quantity" %}: ${data.quantity}`;
var image = blankImage();
if (data.part_detail) {
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
}
if (data.part_detail.description) {
var html = '';
var render_part_detail = true;
if ('render_part_detail' in parameters) {
render_part_detail = parameters['render_part_detail'];
}
if (render_part_detail) {
html += `<img src='${image}' class='select2-thumbnail'>`;
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
}
html += '<span>';
if (data.serial && data.quantity == 1) {
html += `{% trans "Serial Number" %}: ${data.serial}`;
} else {
html += `{% trans "Quantity" %}: ${data.quantity}`;
}
html += '</span>';
if (render_part_detail && data.part_detail.description) {
html += `<p><small>${data.part_detail.description}</small></p>`;
}
var render_stock_id = true;
if ('render_stock_id' in parameters) {
render_stock_id = parameters['render_stock_id'];
}
if (render_stock_id) {
html += `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
}
var render_location_detail = false;
if ('render_location_detail' in parameters) {
render_location_detail = parameters['render_location_detail'];
}
if (render_location_detail && data.location_detail) {
html += `<span> - ${data.location_detail.name}</span>`;
}
return html;
}
@ -75,11 +112,17 @@ function renderStockLocation(name, data, parameters, options) {
var html = `<span>${level}${data.pathstring}</span>`;
if (data.description) {
var render_description = true;
if ('render_description' in parameters) {
render_description = parameters['render_description'];
}
if (render_description && data.description) {
html += ` - <i>${data.description}</i>`;
}
html += `<span class='float-right'>{% trans "Location ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Location ID" %}: ${data.pk}</small></span>`;
return html;
}
@ -96,7 +139,7 @@ function renderBuild(name, data, parameters, options) {
var html = select2Thumbnail(image);
html += `<span><b>${data.reference}</b></span> - ${data.quantity} x ${data.part_detail.full_name}`;
html += `<span class='float-right'>{% trans "Build ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Build ID" %}: ${data.pk}</span></span>`;
html += `<p><i>${data.title}</i></p>`;
@ -116,7 +159,24 @@ function renderPart(name, data, parameters, options) {
html += ` - <i>${data.description}</i>`;
}
html += `<span class='float-right'>{% trans "Part ID" %}: ${data.pk}</span>`;
var stock = '';
// Display available part quantity
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
if (data.in_stock == 0) {
stock = `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
} else {
stock = `<span class='label-form label-green'>{% trans "In Stock" %}: ${data.in_stock}</span>`;
}
}
html += `
<span class='float-right'>
<small>
${stock}
{% trans "Part ID" %}: ${data.pk}
</small>
</span>`;
return html;
}
@ -156,6 +216,26 @@ function renderOwner(name, data, parameters, options) {
}
// Renderer for "SalesOrder" model
// eslint-disable-next-line no-unused-vars
function renderSalesOrder(name, data, parameters, options) {
var html = `<span>${data.reference}</span>`;
if (data.description) {
html += ` - <i>${data.description}</i>`;
}
html += `
<span class='float-right'>
<small>
{% trans "Order ID" %}: ${data.pk}
</small>
</span>`;
return html;
}
// Renderer for "PartCategory" model
// eslint-disable-next-line no-unused-vars
function renderPartCategory(name, data, parameters, options) {
@ -168,7 +248,7 @@ function renderPartCategory(name, data, parameters, options) {
html += ` - <i>${data.description}</i>`;
}
html += `<span class='float-right'>{% trans "Category ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Category ID" %}: ${data.pk}</small></span>`;
return html;
}
@ -205,7 +285,7 @@ function renderManufacturerPart(name, data, parameters, options) {
html += ` <span><b>${data.manufacturer_detail.name}</b> - ${data.MPN}</span>`;
html += ` - <i>${data.part_detail.full_name}</i>`;
html += `<span class='float-right'>{% trans "Manufacturer Part ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Manufacturer Part ID" %}: ${data.pk}</small></span>`;
return html;
}
@ -234,7 +314,7 @@ function renderSupplierPart(name, data, parameters, options) {
html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`;
html += ` - <i>${data.part_detail.full_name}</i>`;
html += `<span class='float-right'>{% trans "Supplier Part ID" %}: ${data.pk}</span>`;
html += `<span class='float-right'><small>{% trans "Supplier Part ID" %}: ${data.pk}</small></span>`;
return html;

File diff suppressed because it is too large Load Diff

View File

@ -4,15 +4,12 @@
/* globals
attachSelect,
attachToggle,
blankImage,
enableField,
clearField,
clearFieldOptions,
closeModal,
constructField,
constructFormBody,
constructNumberInput,
createNewModal,
getFormFieldValue,
global_settings,
handleFormErrors,
@ -101,24 +98,7 @@ function exportStock(params={}) {
required: true,
type: 'choice',
value: 'csv',
choices: [
{
value: 'csv',
display_name: 'CSV',
},
{
value: 'tsv',
display_name: 'TSV',
},
{
value: 'xls',
display_name: 'XLS',
},
{
value: 'xlsx',
display_name: 'XLSX',
},
],
choices: exportFormatOptions(),
},
sublocations: {
label: '{% trans "Include Sublocations" %}',
@ -247,7 +227,7 @@ function adjustStock(action, items, options={}) {
break;
}
var image = item.part_detail.thumbnail || item.part_detail.image || blankImage();
var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image);
var status = stockStatusDisplay(item.status, {
classes: 'float-right'
@ -268,14 +248,18 @@ function adjustStock(action, items, options={}) {
var actionInput = '';
if (actionTitle != null) {
actionInput = constructNumberInput(
item.pk,
actionInput = constructField(
`items_quantity_${pk}`,
{
value: value,
type: 'decimal',
min_value: minValue,
max_value: maxValue,
read_only: readonly,
value: value,
title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}',
required: true,
},
{
hideLabels: true,
}
);
}
@ -293,7 +277,7 @@ function adjustStock(action, items, options={}) {
html += `
<tr id='stock_item_${pk}' class='stock-item-row'>
<td id='part_${pk}'><img src='${image}' class='hover-img-thumb'> ${item.part_detail.full_name}</td>
<td id='part_${pk}'>${thumb} ${item.part_detail.full_name}</td>
<td id='stock_${pk}'>${quantity}${status}</td>
<td id='location_${pk}'>${location}</td>
<td id='action_${pk}'>
@ -319,50 +303,89 @@ function adjustStock(action, items, options={}) {
html += `</tbody></table>`;
var modal = createNewModal({
title: formTitle,
});
var extraFields = {};
// Extra fields
var extraFields = {
location: {
label: '{% trans "Location" %}',
help_text: '{% trans "Select destination stock location" %}',
type: 'related field',
required: true,
api_url: `/api/stock/location/`,
model: 'stocklocation',
name: 'location',
},
notes: {
label: '{% trans "Notes" %}',
help_text: '{% trans "Stock transaction notes" %}',
type: 'string',
name: 'notes',
}
};
if (!specifyLocation) {
delete extraFields.location;
if (specifyLocation) {
extraFields.location = {};
}
constructFormBody({}, {
preFormContent: html,
if (action != 'delete') {
extraFields.notes = {};
}
constructForm(url, {
method: 'POST',
fields: extraFields,
preFormContent: html,
confirm: true,
confirmMessage: '{% trans "Confirm stock adjustment" %}',
modal: modal,
onSubmit: function(fields) {
title: formTitle,
afterRender: function(fields, opts) {
// Add button callbacks to remove rows
$(opts.modal).find('.button-stock-item-remove').click(function() {
var pk = $(this).attr('pk');
// "Delete" action gets handled differently
$(opts.modal).find(`#stock_item_${pk}`).remove();
});
// Initialize "location" field
if (specifyLocation) {
initializeRelatedField(
{
name: 'location',
type: 'related field',
model: 'stocklocation',
required: true,
},
null,
opts
);
}
},
onSubmit: function(fields, opts) {
// Extract data elements from the form
var data = {
items: [],
};
if (action != 'delete') {
data.notes = getFormFieldValue('notes', {}, opts);
}
if (specifyLocation) {
data.location = getFormFieldValue('location', {}, opts);
}
var item_pk_values = [];
items.forEach(function(item) {
var pk = item.pk;
// Does the row exist in the form?
var row = $(opts.modal).find(`#stock_item_${pk}`);
if (row) {
item_pk_values.push(pk);
var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts);
data.items.push({
pk: pk,
quantity: quantity,
});
}
});
// Delete action is handled differently
if (action == 'delete') {
var requests = [];
items.forEach(function(item) {
item_pk_values.forEach(function(pk) {
requests.push(
inventreeDelete(
`/api/stock/${item.pk}/`,
`/api/stock/${pk}/`,
)
);
});
@ -370,72 +393,40 @@ function adjustStock(action, items, options={}) {
// Wait for *all* the requests to complete
$.when.apply($, requests).done(function() {
// Destroy the modal window
$(modal).modal('hide');
$(opts.modal).modal('hide');
if (options.onSuccess) {
options.onSuccess();
if (options.success) {
options.success();
}
});
return;
}
// Data to transmit
var data = {
items: [],
opts.nested = {
'items': item_pk_values,
};
// Add values for each selected stock item
items.forEach(function(item) {
var q = getFormFieldValue(item.pk, {}, {modal: modal});
if (q != null) {
data.items.push({pk: item.pk, quantity: q});
}
});
// Add in extra field data
for (var field_name in extraFields) {
data[field_name] = getFormFieldValue(
field_name,
fields[field_name],
{
modal: modal,
}
);
}
inventreePut(
url,
data,
{
method: 'POST',
success: function() {
success: function(response) {
// Hide the modal
$(opts.modal).modal('hide');
// Destroy the modal window
$(modal).modal('hide');
if (options.onSuccess) {
options.onSuccess();
if (options.success) {
options.success(response);
}
},
error: function(xhr) {
switch (xhr.status) {
case 400:
// Handle errors for standard fields
handleFormErrors(
xhr.responseJSON,
extraFields,
{
modal: modal,
}
);
handleFormErrors(xhr.responseJSON, fields, opts);
break;
default:
$(modal).modal('hide');
$(opts.modal).modal('hide');
showApiError(xhr);
break;
}
@ -444,18 +435,6 @@ function adjustStock(action, items, options={}) {
);
}
});
// Attach callbacks for the action buttons
$(modal).find('.button-stock-item-remove').click(function() {
var pk = $(this).attr('pk');
$(modal).find(`#stock_item_${pk}`).remove();
});
attachToggle(modal);
$(modal + ' .select2-container').addClass('select-full-width');
$(modal + ' .select2-container').css('width', '100%');
}
@ -1258,7 +1237,7 @@ function loadStockTable(table, options) {
var items = $(table).bootstrapTable('getSelections');
adjustStock(action, items, {
onSuccess: function() {
success: function() {
$(table).bootstrapTable('refresh');
}
});

View File

@ -274,7 +274,16 @@ function getAvailableTableFilters(tableKey) {
};
}
// Filters for the "Order" table
// Filters for PurchaseOrderLineItem table
if (tableKey == 'purchaseorderlineitem') {
return {
completed: {
type: 'bool',
title: '{% trans "Completed" %}',
},
};
}
// Filters for the PurchaseOrder table
if (tableKey == 'purchaseorder') {
return {

40
ci/check_api_endpoint.py Normal file
View File

@ -0,0 +1,40 @@
"""
Test that the root API endpoint is available.
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import json
import requests
# We expect the server to be running on the local host
url = "http://localhost:8000/api/"
print("Testing InvenTree API endpoint")
response = requests.get(url)
assert(response.status_code == 200)
print("- Response 200 OK")
data = json.loads(response.text)
required_keys = [
'server',
'version',
'apiVersion',
'worker_running',
]
for key in required_keys:
assert(key in data)
print(f"- Found key '{key}'")
# Check that the worker is running
assert(data['worker_running'])
print("- Background worker is operational")
print("API Endpoint Tests Passed OK")

View File

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