mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-15 05:32:21 +00:00
Merge branch 'inventree:master' into fr-1421-sso
This commit is contained in:
@@ -63,6 +63,13 @@ class InvenTreeConfig(AppConfig):
|
||||
schedule_type=Schedule.DAILY,
|
||||
)
|
||||
|
||||
# Delete "old" stock items
|
||||
InvenTree.tasks.schedule_task(
|
||||
'stock.tasks.delete_old_stock_items',
|
||||
schedule_type=Schedule.MINUTES,
|
||||
minutes=30,
|
||||
)
|
||||
|
||||
def update_exchange_rates(self):
|
||||
"""
|
||||
Update exchange rates each time the server is started, *if*:
|
||||
|
@@ -36,9 +36,14 @@ def health_status(request):
|
||||
'email_configured': InvenTree.status.is_email_configured(),
|
||||
}
|
||||
|
||||
# The following keys are required to denote system health
|
||||
health_keys = [
|
||||
'django_q_running',
|
||||
]
|
||||
|
||||
all_healthy = True
|
||||
|
||||
for k in status.keys():
|
||||
for k in health_keys:
|
||||
if status[k] is not True:
|
||||
all_healthy = False
|
||||
|
||||
|
1
InvenTree/InvenTree/locale_stats.json
Normal file
1
InvenTree/InvenTree/locale_stats.json
Normal file
@@ -0,0 +1 @@
|
||||
{"de": 95, "el": 0, "en": 0, "es": 4, "fr": 6, "he": 0, "id": 0, "it": 0, "ja": 4, "ko": 0, "nl": 0, "no": 0, "pl": 27, "ru": 6, "sv": 0, "th": 0, "tr": 32, "vi": 0, "zh": 1}
|
@@ -98,10 +98,12 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
serializer_info = super().get_serializer_info(serializer)
|
||||
|
||||
try:
|
||||
ModelClass = serializer.Meta.model
|
||||
model_class = None
|
||||
|
||||
model_fields = model_meta.get_field_info(ModelClass)
|
||||
try:
|
||||
model_class = serializer.Meta.model
|
||||
|
||||
model_fields = model_meta.get_field_info(model_class)
|
||||
|
||||
# Iterate through simple fields
|
||||
for name, field in model_fields.fields.items():
|
||||
@@ -146,11 +148,23 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
if hasattr(serializer, 'instance'):
|
||||
instance = serializer.instance
|
||||
|
||||
if instance is None:
|
||||
try:
|
||||
instance = self.view.get_object()
|
||||
except:
|
||||
pass
|
||||
if instance is None and model_class is not None:
|
||||
# Attempt to find the instance based on kwargs lookup
|
||||
kwargs = getattr(self.view, 'kwargs', None)
|
||||
|
||||
if kwargs:
|
||||
pk = None
|
||||
|
||||
for field in ['pk', 'id', 'PK', 'ID']:
|
||||
if field in kwargs:
|
||||
pk = kwargs[field]
|
||||
break
|
||||
|
||||
if pk is not None:
|
||||
try:
|
||||
instance = model_class.objects.get(pk=pk)
|
||||
except (ValueError, model_class.DoesNotExist):
|
||||
pass
|
||||
|
||||
if instance is not None:
|
||||
"""
|
||||
|
@@ -1061,3 +1061,7 @@ input[type='number']{
|
||||
.search-menu .ui-menu-item {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.product-card-panel{
|
||||
height: 100%;
|
||||
}
|
||||
|
@@ -8,13 +8,16 @@ import re
|
||||
|
||||
import common.models
|
||||
|
||||
INVENTREE_SW_VERSION = "0.5.0 pre"
|
||||
INVENTREE_SW_VERSION = "0.5.0 dev"
|
||||
|
||||
INVENTREE_API_VERSION = 11
|
||||
INVENTREE_API_VERSION = 12
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v12 -> 2021-09-07
|
||||
- Adds API endpoint to receive stock items against a PurchaseOrder
|
||||
|
||||
v11 -> 2021-08-26
|
||||
- Adds "units" field to PartBriefSerializer
|
||||
- This allows units to be introspected from the "part_detail" field in the StockItem serializer
|
||||
@@ -67,7 +70,7 @@ def inventreeInstanceTitle():
|
||||
|
||||
def inventreeVersion():
|
||||
""" Returns the InvenTree version string """
|
||||
return INVENTREE_SW_VERSION
|
||||
return INVENTREE_SW_VERSION.lower().strip()
|
||||
|
||||
|
||||
def inventreeVersionTuple(version=None):
|
||||
@@ -81,6 +84,33 @@ def inventreeVersionTuple(version=None):
|
||||
return [int(g) for g in match.groups()]
|
||||
|
||||
|
||||
def isInvenTreeDevelopmentVersion():
|
||||
"""
|
||||
Return True if current InvenTree version is a "development" version
|
||||
"""
|
||||
|
||||
print("is dev?", inventreeVersion())
|
||||
|
||||
return inventreeVersion().endswith('dev')
|
||||
|
||||
|
||||
def inventreeDocsVersion():
|
||||
"""
|
||||
Return the version string matching the latest documentation.
|
||||
|
||||
Development -> "latest"
|
||||
Release -> "major.minor"
|
||||
|
||||
"""
|
||||
|
||||
if isInvenTreeDevelopmentVersion():
|
||||
return "latest"
|
||||
else:
|
||||
major, minor, patch = inventreeVersionTuple()
|
||||
|
||||
return f"{major}.{minor}"
|
||||
|
||||
|
||||
def isInvenTreeUpToDate():
|
||||
"""
|
||||
Test if the InvenTree instance is "up to date" with the latest version.
|
||||
|
@@ -8,8 +8,9 @@ from django.db.utils import IntegrityError
|
||||
from InvenTree import status_codes as status
|
||||
|
||||
from build.models import Build, BuildItem, get_next_build_number
|
||||
from stock.models import StockItem
|
||||
from part.models import Part, BomItem
|
||||
from stock.models import StockItem
|
||||
from stock.tasks import delete_old_stock_items
|
||||
|
||||
|
||||
class BuildTest(TestCase):
|
||||
@@ -352,6 +353,11 @@ class BuildTest(TestCase):
|
||||
# the original BuildItem objects should have been deleted!
|
||||
self.assertEqual(BuildItem.objects.count(), 0)
|
||||
|
||||
self.assertEqual(StockItem.objects.count(), 8)
|
||||
|
||||
# Clean up old stock items
|
||||
delete_old_stock_items()
|
||||
|
||||
# New stock items should have been created!
|
||||
self.assertEqual(StockItem.objects.count(), 7)
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,16 @@ JSON API for the Order 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 django.db import transaction
|
||||
|
||||
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.serializers import ValidationError
|
||||
|
||||
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.helpers import str2bool
|
||||
@@ -28,6 +32,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
|
||||
from .models import SalesOrderAttachment
|
||||
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
||||
from .serializers import SalesOrderAllocationSerializer
|
||||
from .serializers import POReceiveSerializer
|
||||
|
||||
|
||||
class POList(generics.ListCreateAPIView):
|
||||
@@ -205,6 +210,111 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
return queryset
|
||||
|
||||
|
||||
class POReceive(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to receive stock items against a purchase order.
|
||||
|
||||
- The purchase order is specified in the URL.
|
||||
- Items to receive are specified as a list called "items" with the following options:
|
||||
- supplier_part: pk value of the supplier part
|
||||
- quantity: quantity to receive
|
||||
- status: stock item status
|
||||
- location: destination for stock item (optional)
|
||||
- A global location can also be specified
|
||||
"""
|
||||
|
||||
queryset = PurchaseOrderLineItem.objects.none()
|
||||
|
||||
serializer_class = POReceiveSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# Pass the purchase order through to the serializer for validation
|
||||
context['order'] = self.get_order()
|
||||
|
||||
return context
|
||||
|
||||
def get_order(self):
|
||||
"""
|
||||
Returns the PurchaseOrder associated with this API endpoint
|
||||
"""
|
||||
|
||||
pk = self.kwargs.get('pk', None)
|
||||
|
||||
if pk is None:
|
||||
return None
|
||||
else:
|
||||
order = PurchaseOrder.objects.get(pk=self.kwargs['pk'])
|
||||
return order
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
|
||||
# Which purchase order are we receiving against?
|
||||
self.order = self.get_order()
|
||||
|
||||
# Validate the serialized data
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Receive the line items
|
||||
self.receive_items(serializer)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@transaction.atomic
|
||||
def receive_items(self, serializer):
|
||||
"""
|
||||
Receive the items
|
||||
|
||||
At this point, much of the heavy lifting has been done for us by DRF serializers!
|
||||
|
||||
We have a list of "items", each a dict which contains:
|
||||
- line_item: A PurchaseOrderLineItem matching this order
|
||||
- location: A destination location
|
||||
- quantity: A validated numerical quantity
|
||||
- status: The status code for the received item
|
||||
"""
|
||||
|
||||
data = serializer.validated_data
|
||||
|
||||
location = data['location']
|
||||
|
||||
items = data['items']
|
||||
|
||||
# Check if the location is not specified for any particular item
|
||||
for item in items:
|
||||
|
||||
line = item['line_item']
|
||||
|
||||
if not item.get('location', None):
|
||||
# If a global location is specified, use that
|
||||
item['location'] = location
|
||||
|
||||
if not item['location']:
|
||||
# The line item specifies a location?
|
||||
item['location'] = line.get_destination()
|
||||
|
||||
if not item['location']:
|
||||
raise ValidationError({
|
||||
'location': _("Destination location must be specified"),
|
||||
})
|
||||
|
||||
# Now we can actually receive the items
|
||||
for item in items:
|
||||
|
||||
self.order.receive_line_item(
|
||||
item['line_item'],
|
||||
item['location'],
|
||||
item['quantity'],
|
||||
self.request.user,
|
||||
status=item['status'],
|
||||
barcode=item.get('barcode', ''),
|
||||
)
|
||||
|
||||
|
||||
class POLineItemList(generics.ListCreateAPIView):
|
||||
""" API endpoint for accessing a list of POLineItem objects
|
||||
|
||||
@@ -641,13 +751,25 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
|
||||
|
||||
|
||||
order_api_urls = [
|
||||
|
||||
# API endpoints for purchase orders
|
||||
url(r'po/attachment/', include([
|
||||
url(r'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'),
|
||||
url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'),
|
||||
url(r'^po/', include([
|
||||
|
||||
# Purchase order attachments
|
||||
url(r'attachment/', include([
|
||||
url(r'^(?P<pk>\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'),
|
||||
url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'),
|
||||
])),
|
||||
|
||||
# Individual purchase order detail URLs
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^receive/', POReceive.as_view(), name='api-po-receive'),
|
||||
url(r'.*$', PODetail.as_view(), name='api-po-detail'),
|
||||
])),
|
||||
|
||||
# Purchase order list
|
||||
url(r'^.*$', POList.as_view(), name='api-po-list'),
|
||||
])),
|
||||
url(r'^po/(?P<pk>\d+)/$', PODetail.as_view(), name='api-po-detail'),
|
||||
url(r'^po/.*$', POList.as_view(), name='api-po-list'),
|
||||
|
||||
# API endpoints for purchase order line items
|
||||
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
|
||||
|
@@ -411,6 +411,11 @@ class PurchaseOrder(Order):
|
||||
"""
|
||||
|
||||
notes = kwargs.get('notes', '')
|
||||
barcode = kwargs.get('barcode', '')
|
||||
|
||||
# Prevent null values for barcode
|
||||
if barcode is None:
|
||||
barcode = ''
|
||||
|
||||
if not self.status == PurchaseOrderStatus.PLACED:
|
||||
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
|
||||
@@ -433,7 +438,8 @@ class PurchaseOrder(Order):
|
||||
quantity=quantity,
|
||||
purchase_order=self,
|
||||
status=status,
|
||||
purchase_price=purchase_price,
|
||||
purchase_price=line.purchase_price,
|
||||
uid=barcode
|
||||
)
|
||||
|
||||
stock.save(add_note=False)
|
||||
|
@@ -12,6 +12,8 @@ from django.db.models import Case, When, Value
|
||||
from django.db.models import BooleanField, ExpressionWrapper, F
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from sql_util.utils import SubqueryCount
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
@@ -19,8 +21,13 @@ from InvenTree.serializers import InvenTreeAttachmentSerializer
|
||||
from InvenTree.serializers import InvenTreeMoneySerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
import stock.models
|
||||
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
@@ -137,7 +144,6 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
self.fields.pop('part_detail')
|
||||
self.fields.pop('supplier_part_detail')
|
||||
|
||||
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
|
||||
quantity = serializers.FloatField(default=1)
|
||||
received = serializers.FloatField(default=0)
|
||||
|
||||
@@ -182,6 +188,131 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class POLineItemReceiveSerializer(serializers.Serializer):
|
||||
"""
|
||||
A serializer for receiving a single purchase order line item against a purchase order
|
||||
"""
|
||||
|
||||
line_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=PurchaseOrderLineItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Line Item'),
|
||||
)
|
||||
|
||||
def validate_line_item(self, item):
|
||||
|
||||
if item.order != self.context['order']:
|
||||
raise ValidationError(_('Line item does not match purchase order'))
|
||||
|
||||
return item
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=stock.models.StockLocation.objects.all(),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
label=_('Location'),
|
||||
help_text=_('Select destination location for received items'),
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
required=True,
|
||||
)
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=list(StockStatus.items()),
|
||||
default=StockStatus.OK,
|
||||
label=_('Status'),
|
||||
)
|
||||
|
||||
barcode = serializers.CharField(
|
||||
label=_('Barcode Hash'),
|
||||
help_text=_('Unique identifier field'),
|
||||
default='',
|
||||
required=False,
|
||||
)
|
||||
|
||||
def validate_barcode(self, barcode):
|
||||
"""
|
||||
Cannot check in a LineItem with a barcode that is already assigned
|
||||
"""
|
||||
|
||||
# Ignore empty barcode values
|
||||
if not barcode or barcode.strip() == '':
|
||||
return
|
||||
|
||||
if stock.models.StockItem.objects.filter(uid=barcode).exists():
|
||||
raise ValidationError(_('Barcode is already in use'))
|
||||
|
||||
return barcode
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'barcode',
|
||||
'line_item',
|
||||
'location',
|
||||
'quantity',
|
||||
'status',
|
||||
]
|
||||
|
||||
|
||||
class POReceiveSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for receiving items against a purchase order
|
||||
"""
|
||||
|
||||
items = POLineItemReceiveSerializer(many=True)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=stock.models.StockLocation.objects.all(),
|
||||
many=False,
|
||||
allow_null=True,
|
||||
label=_('Location'),
|
||||
help_text=_('Select destination location for received items'),
|
||||
)
|
||||
|
||||
def is_valid(self, raise_exception=False):
|
||||
|
||||
super().is_valid(raise_exception)
|
||||
|
||||
# Custom validation
|
||||
data = self.validated_data
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
if len(items) == 0:
|
||||
self._errors['items'] = _('Line items must be provided')
|
||||
else:
|
||||
# Ensure barcodes are unique
|
||||
unique_barcodes = set()
|
||||
|
||||
for item in items:
|
||||
barcode = item.get('barcode', '')
|
||||
|
||||
if barcode:
|
||||
if barcode in unique_barcodes:
|
||||
self._errors['items'] = _('Supplied barcode values must be unique')
|
||||
break
|
||||
else:
|
||||
unique_barcodes.add(barcode)
|
||||
|
||||
if self._errors and raise_exception:
|
||||
raise ValidationError(self.errors)
|
||||
|
||||
return not bool(self._errors)
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'items',
|
||||
'location',
|
||||
]
|
||||
|
||||
|
||||
class POAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""
|
||||
Serializers for the PurchaseOrderAttachment model
|
||||
|
@@ -38,7 +38,7 @@
|
||||
<h4>{% trans "Received Items" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% include "stock_table.html" with read_only=True %}
|
||||
{% include "stock_table.html" with prevent_new_stock=True %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -201,257 +201,32 @@ $('#new-po-line').click(function() {
|
||||
},
|
||||
method: 'POST',
|
||||
title: '{% trans "Add Line Item" %}',
|
||||
onSuccess: reloadTable,
|
||||
onSuccess: function() {
|
||||
$('#po-line-table').bootstrapTable('refresh');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
function reloadTable() {
|
||||
$("#po-line-table").bootstrapTable("refresh");
|
||||
}
|
||||
|
||||
function setupCallbacks() {
|
||||
// Setup callbacks for the line buttons
|
||||
|
||||
var table = $("#po-line-table");
|
||||
|
||||
loadPurchaseOrderLineItemTable('#po-line-table', {
|
||||
order: {{ order.pk }},
|
||||
supplier: {{ order.supplier.pk }},
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
table.find(".button-line-edit").click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/order/po-line/${pk}/`, {
|
||||
fields: {
|
||||
part: {
|
||||
filters: {
|
||||
part_detail: true,
|
||||
supplier_detail: true,
|
||||
supplier: {{ order.supplier.pk }},
|
||||
}
|
||||
},
|
||||
quantity: {},
|
||||
reference: {},
|
||||
purchase_price: {},
|
||||
purchase_price_currency: {},
|
||||
destination: {},
|
||||
notes: {},
|
||||
},
|
||||
title: '{% trans "Edit Line Item" %}',
|
||||
onSuccess: reloadTable,
|
||||
});
|
||||
});
|
||||
|
||||
table.find(".button-line-delete").click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/order/po-line/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Line Item" %}',
|
||||
onSuccess: reloadTable,
|
||||
});
|
||||
});
|
||||
allow_edit: true,
|
||||
{% else %}
|
||||
allow_edit: false,
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
|
||||
allow_receive: true,
|
||||
{% else %}
|
||||
allow_receive: false,
|
||||
{% endif %}
|
||||
|
||||
table.find(".button-line-receive").click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm("{% url 'po-receive' order.id %}", {
|
||||
success: reloadTable,
|
||||
data: {
|
||||
line: pk,
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'location',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new stock location" %}',
|
||||
url: "{% url 'stock-location-create' %}",
|
||||
},
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
$("#po-line-table").inventreeTable({
|
||||
onPostBody: setupCallbacks,
|
||||
name: 'purchaseorderlines',
|
||||
sidePagination: 'server',
|
||||
formatNoMatches: function() { return "{% trans 'No line items found' %}"; },
|
||||
queryParams: {
|
||||
order: {{ order.id }},
|
||||
part_detail: true,
|
||||
},
|
||||
url: "{% url 'api-po-line-list' %}",
|
||||
showFooter: true,
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'part',
|
||||
sortable: true,
|
||||
sortName: 'part_name',
|
||||
title: '{% trans "Part" %}',
|
||||
switchable: false,
|
||||
formatter: function(value, row, index, field) {
|
||||
if (row.part) {
|
||||
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`);
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
footerFormatter: function() {
|
||||
return '{% trans "Total" %}'
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'part_detail.description',
|
||||
title: '{% trans "Description" %}',
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
sortName: 'SKU',
|
||||
field: 'supplier_part_detail.SKU',
|
||||
title: '{% trans "SKU" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
if (value) {
|
||||
return renderLink(value, `/supplier-part/${row.part}/`);
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
sortName: 'MPN',
|
||||
field: 'supplier_part_detail.manufacturer_part_detail.MPN',
|
||||
title: '{% trans "MPN" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) {
|
||||
return renderLink(value, `/manufacturer-part/${row.supplier_part_detail.manufacturer_part}/`);
|
||||
} else {
|
||||
return "-";
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'purchase_price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
return row.purchase_price_string || row.purchase_price;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'total_price',
|
||||
sortable: true,
|
||||
field: 'total_price',
|
||||
title: '{% trans "Total price" %}',
|
||||
formatter: function(value, row) {
|
||||
var total = row.purchase_price * row.quantity;
|
||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.purchase_price_currency});
|
||||
return formatter.format(total)
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function (row) {
|
||||
return +row['purchase_price']*row['quantity']
|
||||
}).reduce(function (sum, i) {
|
||||
return sum + i
|
||||
}, 0)
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
|
||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
|
||||
return formatter.format(total)
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: false,
|
||||
field: 'received',
|
||||
switchable: false,
|
||||
title: '{% trans "Received" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
return makeProgressBar(row.received, row.quantity, {
|
||||
id: `order-line-progress-${row.pk}`,
|
||||
});
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
|
||||
if (rowA.received == 0 && rowB.received == 0) {
|
||||
return (rowA.quantity > rowB.quantity) ? 1 : -1;
|
||||
}
|
||||
|
||||
var progressA = parseFloat(rowA.received) / rowA.quantity;
|
||||
var progressB = parseFloat(rowB.received) / rowB.quantity;
|
||||
|
||||
return (progressA < progressB) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'destination',
|
||||
title: '{% trans "Destination" %}',
|
||||
formatter: function(value, row) {
|
||||
if (value) {
|
||||
return renderLink(row.destination_detail.pathstring, `/stock/location/${value}/`);
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'notes',
|
||||
title: '{% trans "Notes" %}',
|
||||
},
|
||||
{
|
||||
switchable: false,
|
||||
field: 'buttons',
|
||||
title: '',
|
||||
formatter: function(value, row, index, field) {
|
||||
var html = `<div class='btn-group'>`;
|
||||
|
||||
var pk = row.pk;
|
||||
|
||||
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.delete %}
|
||||
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
|
||||
{% endif %}
|
||||
|
||||
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
|
||||
if (row.received < row.quantity) {
|
||||
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
},
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
attachNavCallbacks({
|
||||
name: 'purchase-order',
|
||||
default: 'order-items'
|
||||
});
|
||||
attachNavCallbacks({
|
||||
name: 'purchase-order',
|
||||
default: 'order-items'
|
||||
});
|
||||
|
||||
{% endblock %}
|
@@ -9,8 +9,11 @@ from rest_framework import status
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
|
||||
from .models import PurchaseOrder, SalesOrder
|
||||
from stock.models import StockItem
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder
|
||||
|
||||
|
||||
class OrderTest(InvenTreeAPITestCase):
|
||||
@@ -201,6 +204,250 @@ class PurchaseOrderTest(OrderTest):
|
||||
response = self.get(url, expected_code=404)
|
||||
|
||||
|
||||
class PurchaseOrderReceiveTest(OrderTest):
|
||||
"""
|
||||
Unit tests for receiving items against a PurchaseOrder
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.assignRole('purchase_order.add')
|
||||
|
||||
self.url = reverse('api-po-receive', kwargs={'pk': 1})
|
||||
|
||||
# Number of stock items which exist at the start of each test
|
||||
self.n = StockItem.objects.count()
|
||||
|
||||
# Mark the order as "placed" so we can receive line items
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
order.status = PurchaseOrderStatus.PLACED
|
||||
order.save()
|
||||
|
||||
def test_empty(self):
|
||||
"""
|
||||
Test without any POST data
|
||||
"""
|
||||
|
||||
data = self.post(self.url, {}, expected_code=400).data
|
||||
|
||||
self.assertIn('This field is required', str(data['items']))
|
||||
self.assertIn('This field is required', str(data['location']))
|
||||
|
||||
# No new stock items have been created
|
||||
self.assertEqual(self.n, StockItem.objects.count())
|
||||
|
||||
def test_no_items(self):
|
||||
"""
|
||||
Test with an empty list of items
|
||||
"""
|
||||
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [],
|
||||
"location": None,
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn('Line items must be provided', str(data['items']))
|
||||
|
||||
# No new stock items have been created
|
||||
self.assertEqual(self.n, StockItem.objects.count())
|
||||
|
||||
def test_invalid_items(self):
|
||||
"""
|
||||
Test than errors are returned as expected for invalid data
|
||||
"""
|
||||
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"line_item": 12345,
|
||||
"location": 12345
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
items = data['items'][0]
|
||||
|
||||
self.assertIn('Invalid pk "12345"', str(items['line_item']))
|
||||
self.assertIn("object does not exist", str(items['location']))
|
||||
|
||||
# No new stock items have been created
|
||||
self.assertEqual(self.n, StockItem.objects.count())
|
||||
|
||||
def test_invalid_status(self):
|
||||
"""
|
||||
Test with an invalid StockStatus value
|
||||
"""
|
||||
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"line_item": 22,
|
||||
"location": 1,
|
||||
"status": 99999,
|
||||
"quantity": 5,
|
||||
}
|
||||
]
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn('"99999" is not a valid choice.', str(data))
|
||||
|
||||
# No new stock items have been created
|
||||
self.assertEqual(self.n, StockItem.objects.count())
|
||||
|
||||
def test_mismatched_items(self):
|
||||
"""
|
||||
Test for supplier parts which *do* exist but do not match the order supplier
|
||||
"""
|
||||
|
||||
data = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'line_item': 22,
|
||||
'quantity': 123,
|
||||
'location': 1,
|
||||
}
|
||||
],
|
||||
'location': None,
|
||||
},
|
||||
expected_code=400
|
||||
).data
|
||||
|
||||
self.assertIn('Line item does not match purchase order', str(data))
|
||||
|
||||
# No new stock items have been created
|
||||
self.assertEqual(self.n, StockItem.objects.count())
|
||||
|
||||
def test_invalid_barcodes(self):
|
||||
"""
|
||||
Tests for checking in items with invalid barcodes:
|
||||
|
||||
- Cannot check in "duplicate" barcodes
|
||||
- Barcodes cannot match UID field for existing StockItem
|
||||
"""
|
||||
|
||||
# Set stock item barcode
|
||||
item = StockItem.objects.get(pk=1)
|
||||
item.uid = 'MY-BARCODE-HASH'
|
||||
item.save()
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'line_item': 1,
|
||||
'quantity': 50,
|
||||
'barcode': 'MY-BARCODE-HASH',
|
||||
}
|
||||
],
|
||||
'location': 1,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Barcode is already in use', str(response.data))
|
||||
|
||||
response = self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'line_item': 1,
|
||||
'quantity': 5,
|
||||
'barcode': 'MY-BARCODE-HASH-1',
|
||||
},
|
||||
{
|
||||
'line_item': 1,
|
||||
'quantity': 5,
|
||||
'barcode': 'MY-BARCODE-HASH-1'
|
||||
},
|
||||
],
|
||||
'location': 1,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('barcode values must be unique', str(response.data))
|
||||
|
||||
# No new stock items have been created
|
||||
self.assertEqual(self.n, StockItem.objects.count())
|
||||
|
||||
def test_valid(self):
|
||||
"""
|
||||
Test receipt of valid data
|
||||
"""
|
||||
|
||||
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
|
||||
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
|
||||
|
||||
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
|
||||
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
|
||||
|
||||
self.assertEqual(line_1.received, 0)
|
||||
self.assertEqual(line_2.received, 50)
|
||||
|
||||
# Receive two separate line items against this order
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{
|
||||
'line_item': 1,
|
||||
'quantity': 50,
|
||||
'barcode': 'MY-UNIQUE-BARCODE-123',
|
||||
},
|
||||
{
|
||||
'line_item': 2,
|
||||
'quantity': 200,
|
||||
'location': 2, # Explicit location
|
||||
'barcode': 'MY-UNIQUE-BARCODE-456',
|
||||
}
|
||||
],
|
||||
'location': 1, # Default location
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# There should be two newly created stock items
|
||||
self.assertEqual(self.n + 2, StockItem.objects.count())
|
||||
|
||||
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
|
||||
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
|
||||
|
||||
self.assertEqual(line_1.received, 50)
|
||||
self.assertEqual(line_2.received, 250)
|
||||
|
||||
stock_1 = StockItem.objects.filter(supplier_part=line_1.part)
|
||||
stock_2 = StockItem.objects.filter(supplier_part=line_2.part)
|
||||
|
||||
# 1 new stock item created for each supplier part
|
||||
self.assertEqual(stock_1.count(), 1)
|
||||
self.assertEqual(stock_2.count(), 1)
|
||||
|
||||
# Different location for each received item
|
||||
self.assertEqual(stock_1.last().location.pk, 1)
|
||||
self.assertEqual(stock_2.last().location.pk, 2)
|
||||
|
||||
# Barcodes should have been assigned to the stock items
|
||||
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists())
|
||||
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists())
|
||||
|
||||
|
||||
class SalesOrderTest(OrderTest):
|
||||
"""
|
||||
Tests for the SalesOrder API
|
||||
|
@@ -667,6 +667,8 @@
|
||||
});
|
||||
|
||||
onPanelLoad("test-templates", function() {
|
||||
|
||||
// Load test template table
|
||||
loadPartTestTemplateTable(
|
||||
$("#test-template-table"),
|
||||
{
|
||||
@@ -677,11 +679,8 @@
|
||||
}
|
||||
);
|
||||
|
||||
// Callback for "add test template" button
|
||||
$("#add-test-template").click(function() {
|
||||
|
||||
function reloadTestTemplateTable() {
|
||||
$("#test-template-table").bootstrapTable("refresh");
|
||||
}
|
||||
|
||||
constructForm('{% url "api-part-test-template-list" %}', {
|
||||
method: 'POST',
|
||||
@@ -697,39 +696,10 @@
|
||||
}
|
||||
},
|
||||
title: '{% trans "Add Test Result Template" %}',
|
||||
onSuccess: reloadTestTemplateTable
|
||||
onSuccess: function() {
|
||||
$("#test-template-table").bootstrapTable("refresh");
|
||||
}
|
||||
});
|
||||
|
||||
$("#test-template-table").on('click', '.button-test-edit', function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
var url = `/api/part/test-template/${pk}/`;
|
||||
|
||||
constructForm(url, {
|
||||
fields: {
|
||||
test_name: {},
|
||||
description: {},
|
||||
required: {},
|
||||
requires_value: {},
|
||||
requires_attachment: {},
|
||||
},
|
||||
title: '{% trans "Edit Test Result Template" %}',
|
||||
onSuccess: reloadTestTemplateTable,
|
||||
});
|
||||
});
|
||||
|
||||
$("#test-template-table").on('click', '.button-test-delete', function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
var url = `/api/part/test-template/${pk}/`;
|
||||
|
||||
constructForm(url, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Test Result Template" %}',
|
||||
onSuccess: reloadTestTemplateTable,
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -136,6 +136,21 @@ def inventree_version(*args, **kwargs):
|
||||
return version.inventreeVersion()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_is_development(*args, **kwargs):
|
||||
return version.isInvenTreeDevelopmentVersion()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_is_release(*args, **kwargs):
|
||||
return not version.isInvenTreeDevelopmentVersion()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_docs_version(*args, **kwargs):
|
||||
return version.inventreeDocsVersion()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_api_version(*args, **kwargs):
|
||||
""" Return InvenTree API version """
|
||||
@@ -169,7 +184,10 @@ def inventree_github_url(*args, **kwargs):
|
||||
@register.simple_tag()
|
||||
def inventree_docs_url(*args, **kwargs):
|
||||
""" Return URL for InvenTree documenation site """
|
||||
return "https://inventree.readthedocs.io/"
|
||||
|
||||
tag = version.inventreeDocsVersion()
|
||||
|
||||
return f"https://inventree.readthedocs.io/en/{tag}"
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
|
@@ -588,6 +588,27 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(new_part.supplier_parts.count(), 1)
|
||||
self.assertEqual(new_part.manufacturer_parts.count(), 1)
|
||||
|
||||
def test_strange_chars(self):
|
||||
"""
|
||||
Test that non-standard ASCII chars are accepted
|
||||
"""
|
||||
|
||||
url = reverse('api-part-list')
|
||||
|
||||
name = "Kaltgerätestecker"
|
||||
description = "Gerät"
|
||||
|
||||
data = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"category": 2
|
||||
}
|
||||
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
self.assertEqual(response.data['name'], name)
|
||||
self.assertEqual(response.data['description'], description)
|
||||
|
||||
|
||||
class PartDetailTests(InvenTreeAPITestCase):
|
||||
"""
|
||||
|
@@ -653,6 +653,9 @@ class StockList(generics.ListCreateAPIView):
|
||||
|
||||
queryset = StockItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
# Do not expose StockItem objects which are scheduled for deletion
|
||||
queryset = queryset.filter(scheduled_for_deletion=False)
|
||||
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.4 on 2021-09-07 06:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0065_auto_20210701_0509'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='scheduled_for_deletion',
|
||||
field=models.BooleanField(default=False, help_text='This StockItem will be deleted by the background worker', verbose_name='Scheduled for deletion'),
|
||||
),
|
||||
]
|
@@ -209,12 +209,18 @@ class StockItem(MPTTModel):
|
||||
belongs_to=None,
|
||||
customer=None,
|
||||
is_building=False,
|
||||
status__in=StockStatus.AVAILABLE_CODES
|
||||
status__in=StockStatus.AVAILABLE_CODES,
|
||||
scheduled_for_deletion=False,
|
||||
)
|
||||
|
||||
# A query filter which can be used to filter StockItem objects which have expired
|
||||
EXPIRED_FILTER = IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=datetime.now().date())
|
||||
|
||||
def mark_for_deletion(self):
|
||||
|
||||
self.scheduled_for_deletion = True
|
||||
self.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Save this StockItem to the database. Performs a number of checks:
|
||||
@@ -588,6 +594,12 @@ class StockItem(MPTTModel):
|
||||
help_text=_('Select Owner'),
|
||||
related_name='stock_items')
|
||||
|
||||
scheduled_for_deletion = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Scheduled for deletion'),
|
||||
help_text=_('This StockItem will be deleted by the background worker'),
|
||||
)
|
||||
|
||||
def is_stale(self):
|
||||
"""
|
||||
Returns True if this Stock item is "stale".
|
||||
@@ -1294,9 +1306,8 @@ class StockItem(MPTTModel):
|
||||
self.quantity = quantity
|
||||
|
||||
if quantity == 0 and self.delete_on_deplete and self.can_delete():
|
||||
self.mark_for_deletion()
|
||||
|
||||
# TODO - Do not actually "delete" stock at this point - instead give it a "DELETED" flag
|
||||
self.delete()
|
||||
return False
|
||||
else:
|
||||
self.save()
|
||||
|
35
InvenTree/stock/tasks.py
Normal file
35
InvenTree/stock/tasks.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def delete_old_stock_items():
|
||||
"""
|
||||
This function removes StockItem objects which have been marked for deletion.
|
||||
|
||||
Bulk "delete" operations for database entries with foreign-key relationships
|
||||
can be pretty expensive, and thus can "block" the UI for a period of time.
|
||||
|
||||
Thus, instead of immediately deleting multiple StockItems, some UI actions
|
||||
simply mark each StockItem as "scheduled for deletion".
|
||||
|
||||
The background worker then manually deletes these at a later stage
|
||||
"""
|
||||
|
||||
try:
|
||||
from stock.models import StockItem
|
||||
except AppRegistryNotReady:
|
||||
logger.info("Could not delete scheduled StockItems - AppRegistryNotReady")
|
||||
return
|
||||
|
||||
items = StockItem.objects.filter(scheduled_for_deletion=True)
|
||||
|
||||
if items.count() > 0:
|
||||
logger.info(f"Removing {items.count()} StockItem objects scheduled for deletion")
|
||||
items.delete()
|
@@ -332,6 +332,8 @@ class StockTest(TestCase):
|
||||
w1 = StockItem.objects.get(pk=100)
|
||||
w2 = StockItem.objects.get(pk=101)
|
||||
|
||||
self.assertFalse(w2.scheduled_for_deletion)
|
||||
|
||||
# Take 25 units from w1 (there are only 10 in stock)
|
||||
w1.take_stock(30, None, notes='Took 30')
|
||||
|
||||
@@ -342,6 +344,16 @@ class StockTest(TestCase):
|
||||
# Take 25 units from w2 (will be deleted)
|
||||
w2.take_stock(30, None, notes='Took 30')
|
||||
|
||||
# w2 should now be marked for future deletion
|
||||
w2 = StockItem.objects.get(pk=101)
|
||||
self.assertTrue(w2.scheduled_for_deletion)
|
||||
|
||||
from stock.tasks import delete_old_stock_items
|
||||
|
||||
# Now run the "background task" to delete these stock items
|
||||
delete_old_stock_items()
|
||||
|
||||
# This StockItem should now have been deleted
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
w2 = StockItem.objects.get(pk=101)
|
||||
|
||||
|
@@ -22,13 +22,39 @@
|
||||
<td>{% trans "InvenTree Version" %}</td>
|
||||
<td>
|
||||
<a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a>{% include "clip.html" %}
|
||||
{% inventree_is_development as dev %}
|
||||
{% if dev %}
|
||||
<span class='label label-blue float-right'>{% trans "Development Version" %}</span>
|
||||
{% else %}
|
||||
{% if up_to_date %}
|
||||
<span class='label label-green float-right'>{% trans "Up to Date" %}</span>
|
||||
{% else %}
|
||||
<span class='label label-red float-right'>{% trans "Update Available" %}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if dev %}
|
||||
{% inventree_commit_hash as hash %}
|
||||
{% if hash %}
|
||||
<tr>
|
||||
<td><span class='fas fa-code-branch'></span></td>
|
||||
<td>{% trans "Commit Hash" %}</td><td>{{ hash }}{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% inventree_commit_date as commit_date %}
|
||||
{% if commit_date %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Commit Date" %}</td><td>{{ commit_date }}{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-book'></span></td>
|
||||
<td>{% trans "InvenTree Documentation" %}</td>
|
||||
<td><a href="{% inventree_docs_url %}">{% inventree_docs_url %}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-code'></span></td>
|
||||
<td>{% trans "API Version" %}</td>
|
||||
@@ -44,25 +70,6 @@
|
||||
<td>{% trans "Django Version" %}</td>
|
||||
<td><a href="https://www.djangoproject.com/">{% django_version %}</a>{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
{% inventree_commit_hash as hash %}
|
||||
{% if hash %}
|
||||
<tr>
|
||||
<td><span class='fas fa-code-branch'></span></td>
|
||||
<td>{% trans "Commit Hash" %}</td><td>{{ hash }}{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% inventree_commit_date as commit_date %}
|
||||
{% if commit_date %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Commit Date" %}</td><td>{{ commit_date }}{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-book'></span></td>
|
||||
<td>{% trans "InvenTree Documentation" %}</td>
|
||||
<td><a href="{% inventree_docs_url %}">{% inventree_docs_url %}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fab fa-github'></span></td>
|
||||
<td>{% trans "View Code on GitHub" %}</td>
|
||||
|
@@ -286,6 +286,8 @@ function constructForm(url, options) {
|
||||
constructFormBody({}, options);
|
||||
}
|
||||
|
||||
options.fields = options.fields || {};
|
||||
|
||||
// Save the URL
|
||||
options.url = url;
|
||||
|
||||
@@ -545,6 +547,11 @@ function constructFormBody(fields, options) {
|
||||
|
||||
initializeGroups(fields, options);
|
||||
|
||||
if (options.afterRender) {
|
||||
// Custom callback function after form rendering
|
||||
options.afterRender(fields, options);
|
||||
}
|
||||
|
||||
// Scroll to the top
|
||||
$(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
|
||||
}
|
||||
@@ -1542,7 +1549,9 @@ function constructField(name, parameters, options) {
|
||||
html += `<div id='div_${field_name}' class='${form_classes}'>`;
|
||||
|
||||
// Add a label
|
||||
html += constructLabel(name, parameters);
|
||||
if (!options.hideLabels) {
|
||||
html += constructLabel(name, parameters);
|
||||
}
|
||||
|
||||
html += `<div class='controls'>`;
|
||||
|
||||
@@ -1589,7 +1598,7 @@ function constructField(name, parameters, options) {
|
||||
html += `</div>`; // input-group
|
||||
}
|
||||
|
||||
if (parameters.help_text) {
|
||||
if (parameters.help_text && !options.hideLabels) {
|
||||
html += constructHelpText(name, parameters, options);
|
||||
}
|
||||
|
||||
|
@@ -10,6 +10,7 @@
|
||||
makeProgressBar,
|
||||
renderLink,
|
||||
select2Thumbnail,
|
||||
thumbnailImage
|
||||
yesNoLabel,
|
||||
*/
|
||||
|
||||
@@ -56,6 +57,26 @@ function imageHoverIcon(url) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Renders a simple thumbnail image
|
||||
* @param {String} url is the image URL
|
||||
* @returns html <img> tag
|
||||
*/
|
||||
function thumbnailImage(url) {
|
||||
|
||||
if (!url) {
|
||||
url = '/static/img/blank_img.png';
|
||||
}
|
||||
|
||||
// TODO: Support insertion of custom classes
|
||||
|
||||
var html = `<img class='hover-img-thumb' src='${url}'>`;
|
||||
|
||||
return html;
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Render a select2 thumbnail image
|
||||
function select2Thumbnail(image) {
|
||||
if (!image) {
|
||||
|
@@ -793,14 +793,25 @@ function attachSecondaries(modal, secondaries) {
|
||||
function insertActionButton(modal, options) {
|
||||
/* Insert a custom submission button */
|
||||
|
||||
var html = `
|
||||
<span style='float: right;'>
|
||||
<button name='${options.name}' type='submit' class='btn btn-default modal-form-button' value='${options.name}'>
|
||||
${options.title}
|
||||
</button>
|
||||
</span>`;
|
||||
var element = $(modal).find('#modal-footer-buttons');
|
||||
|
||||
$(modal).find('#modal-footer-buttons').append(html);
|
||||
// check if button already present
|
||||
var already_present = false;
|
||||
for (var child=element[0].firstElementChild; child; child=child.nextElementSibling) {
|
||||
if (item.firstElementChild.name == options.name) {
|
||||
already_present = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (already_present == false) {
|
||||
var html = `
|
||||
<span style='float: right;'>
|
||||
<button name='${options.name}' type='submit' class='btn btn-default modal-form-button' value='${options.name}'>
|
||||
${options.title}
|
||||
</button>
|
||||
</span>`;
|
||||
element.append(html);
|
||||
}
|
||||
}
|
||||
|
||||
function attachButtons(modal, buttons) {
|
||||
|
@@ -20,6 +20,7 @@
|
||||
/* exported
|
||||
createSalesOrder,
|
||||
editPurchaseOrderLineItem,
|
||||
loadPurchaseOrderLineItemTable,
|
||||
loadPurchaseOrderTable,
|
||||
loadSalesOrderAllocationTable,
|
||||
loadSalesOrderTable,
|
||||
@@ -144,7 +145,6 @@ function newSupplierPartFromOrderWizard(e) {
|
||||
|
||||
if (!part) {
|
||||
part = $(src).closest('button').attr('part');
|
||||
console.log('parent: ' + part);
|
||||
}
|
||||
|
||||
createSupplierPart({
|
||||
@@ -367,6 +367,271 @@ function loadPurchaseOrderTable(table, options) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load a table displaying line items for a particular PurchasesOrder
|
||||
* @param {String} table - HTML ID tag e.g. '#table'
|
||||
* @param {Object} options - options which must provide:
|
||||
* - order (integer PK)
|
||||
* - supplier (integer PK)
|
||||
* - allow_edit (boolean)
|
||||
* - allow_receive (boolean)
|
||||
*/
|
||||
function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
|
||||
function setupCallbacks() {
|
||||
if (options.allow_edit) {
|
||||
$(table).find('.button-line-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/order/po-line/${pk}/`, {
|
||||
fields: {
|
||||
part: {
|
||||
filters: {
|
||||
part_detail: true,
|
||||
supplier_detail: true,
|
||||
supplier: options.supplier,
|
||||
}
|
||||
},
|
||||
quantity: {},
|
||||
reference: {},
|
||||
purchase_price: {},
|
||||
purchase_price_currency: {},
|
||||
destination: {},
|
||||
notes: {},
|
||||
},
|
||||
title: '{% trans "Edit Line Item" %}',
|
||||
onSuccess: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(table).find('.button-line-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/order/po-line/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Line Item" %}',
|
||||
onSuccess: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (options.allow_receive) {
|
||||
$(table).find('.button-line-receive').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
launchModalForm(`/order/purchase-order/${options.order}/receive/`, {
|
||||
success: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
},
|
||||
data: {
|
||||
line: pk,
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'location',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new stock location" %}',
|
||||
url: '{% url "stock-location-create" %}',
|
||||
},
|
||||
]
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$(table).inventreeTable({
|
||||
onPostBody: setupCallbacks,
|
||||
name: 'purchaseorderlines',
|
||||
sidePagination: 'server',
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No line items found" %}';
|
||||
},
|
||||
queryParams: {
|
||||
order: options.order,
|
||||
part_detail: true
|
||||
},
|
||||
url: '{% url "api-po-line-list" %}',
|
||||
showFooter: true,
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'part',
|
||||
sortable: true,
|
||||
sortName: 'part_name',
|
||||
title: '{% trans "Part" %}',
|
||||
switchable: false,
|
||||
formatter: function(value, row, index, field) {
|
||||
if (row.part) {
|
||||
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`);
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
footerFormatter: function() {
|
||||
return '{% trans "Total" %}';
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'part_detail.description',
|
||||
title: '{% trans "Description" %}',
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
sortName: 'SKU',
|
||||
field: 'supplier_part_detail.SKU',
|
||||
title: '{% trans "SKU" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
if (value) {
|
||||
return renderLink(value, `/supplier-part/${row.part}/`);
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
sortName: 'MPN',
|
||||
field: 'supplier_part_detail.manufacturer_part_detail.MPN',
|
||||
title: '{% trans "MPN" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) {
|
||||
return renderLink(value, `/manufacturer-part/${row.supplier_part_detail.manufacturer_part}/`);
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'purchase_price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
return row.purchase_price_string || row.purchase_price;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'total_price',
|
||||
sortable: true,
|
||||
field: 'total_price',
|
||||
title: '{% trans "Total price" %}',
|
||||
formatter: function(value, row) {
|
||||
var total = row.purchase_price * row.quantity;
|
||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.purchase_price_currency});
|
||||
return formatter.format(total);
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function(row) {
|
||||
return +row['purchase_price']*row['quantity'];
|
||||
}).reduce(function(sum, i) {
|
||||
return sum + i;
|
||||
}, 0);
|
||||
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
|
||||
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(total);
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: false,
|
||||
field: 'received',
|
||||
switchable: false,
|
||||
title: '{% trans "Received" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
return makeProgressBar(row.received, row.quantity, {
|
||||
id: `order-line-progress-${row.pk}`,
|
||||
});
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
|
||||
if (rowA.received == 0 && rowB.received == 0) {
|
||||
return (rowA.quantity > rowB.quantity) ? 1 : -1;
|
||||
}
|
||||
|
||||
var progressA = parseFloat(rowA.received) / rowA.quantity;
|
||||
var progressB = parseFloat(rowB.received) / rowB.quantity;
|
||||
|
||||
return (progressA < progressB) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'destination',
|
||||
title: '{% trans "Destination" %}',
|
||||
formatter: function(value, row) {
|
||||
if (value) {
|
||||
return renderLink(row.destination_detail.pathstring, `/stock/location/${value}/`);
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'notes',
|
||||
title: '{% trans "Notes" %}',
|
||||
},
|
||||
{
|
||||
switchable: false,
|
||||
field: 'buttons',
|
||||
title: '',
|
||||
formatter: function(value, row, index, field) {
|
||||
var html = `<div class='btn-group'>`;
|
||||
|
||||
var pk = row.pk;
|
||||
|
||||
if (options.allow_edit) {
|
||||
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
|
||||
}
|
||||
|
||||
if (options.allow_receive && row.received < row.quantity) {
|
||||
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
},
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function loadSalesOrderTable(table, options) {
|
||||
|
||||
options.params = options.params || {};
|
||||
|
@@ -768,7 +768,7 @@ function partGridTile(part) {
|
||||
var html = `
|
||||
|
||||
<div class='col-sm-3 card'>
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel panel-default panel-inventree product-card-panel'>
|
||||
<div class='panel-heading'>
|
||||
<a href='/part/${part.pk}/'>
|
||||
<b>${part.full_name}</b>
|
||||
@@ -1007,7 +1007,7 @@ function loadPartTable(table, url, options={}) {
|
||||
|
||||
// Force a new row every 4 columns, to prevent visual issues
|
||||
if ((index > 0) && (index % 4 == 0) && (index < data.length)) {
|
||||
html += `</div><div class='row'>`;
|
||||
html += `</div><div class='row full-height'>`;
|
||||
}
|
||||
|
||||
html += partGridTile(row);
|
||||
@@ -1252,7 +1252,43 @@ function loadPartTestTemplateTable(table, options) {
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
onPostBody: function() {
|
||||
|
||||
table.find('.button-test-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
var url = `/api/part/test-template/${pk}/`;
|
||||
|
||||
constructForm(url, {
|
||||
fields: {
|
||||
test_name: {},
|
||||
description: {},
|
||||
required: {},
|
||||
requires_value: {},
|
||||
requires_attachment: {},
|
||||
},
|
||||
title: '{% trans "Edit Test Result Template" %}',
|
||||
onSuccess: function() {
|
||||
table.bootstrapTable('refresh');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
table.find('.button-test-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
var url = `/api/part/test-template/${pk}/`;
|
||||
|
||||
constructForm(url, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Test Result Template" %}',
|
||||
onSuccess: function() {
|
||||
table.bootstrapTable('refresh');
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -71,11 +71,7 @@
|
||||
<a class='dropdown-toggle' data-toggle='dropdown' href="#">
|
||||
{% if user.is_staff %}
|
||||
{% if not system_healthy %}
|
||||
{% if not django_q_running %}
|
||||
<span class='fas fa-exclamation-triangle icon-red'></span>
|
||||
{% else %}
|
||||
<span class='fas fa-exclamation-triangle icon-orange'></span>
|
||||
{% endif %}
|
||||
{% elif not up_to_date %}
|
||||
<span class='fas fa-info-circle icon-green'></span>
|
||||
{% endif %}
|
||||
@@ -96,11 +92,7 @@
|
||||
{% if system_healthy or not user.is_staff %}
|
||||
<span class='fas fa-server'></span>
|
||||
{% else %}
|
||||
{% if not django_q_running %}
|
||||
<span class='fas fa-server icon-red'></span>
|
||||
{% else %}
|
||||
<span class='fas fa-server icon-orange'></span>
|
||||
{% endif %}
|
||||
<span class='fas fa-server icon-red'></span>
|
||||
{% endif %}
|
||||
</span> {% trans "System Information" %}
|
||||
</a></li>
|
||||
|
@@ -16,7 +16,7 @@
|
||||
</button>
|
||||
|
||||
{% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %}
|
||||
{% if not read_only and roles.stock.add %}
|
||||
{% if not read_only and not prevent_new_stock and roles.stock.add %}
|
||||
<button class="btn btn-success" id='item-create' title='{% trans "New Stock Item" %}'>
|
||||
<span class='fas fa-plus-circle'></span>
|
||||
</button>
|
||||
|
Reference in New Issue
Block a user