2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-30 12:45:42 +00:00

[refactor] PO receive fix (#10174)

* Enhance 'count_queries' helper

* Add threshold

* Update typing

* Better rendering

* Improve StockItem

- Make model operations more efficient

* improve with_mixin

- Cache config map against the session cache

* Refactor receive_line_item

- Pass multiple line items in simultaneously
- DB query optimization
- Use bulk_create and bulk_update operations

* Remove extraneous save call

* Fix for unit tests

* Fix return type

* Fix serializer return type

* Refactor part pricing updates

* UI tweaks

* Use bulk_create

* Refactor API and endpoints

* Bump API version

* Fix unit tests

* Fix playwright tests

* Remove debug msg

* Fix for table filter hover

* Adjust unit test
This commit is contained in:
Oliver
2025-08-15 22:01:19 +10:00
committed by GitHub
parent 0f04c31ffb
commit 0bed5cf511
21 changed files with 519 additions and 313 deletions

View File

@@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 384
INVENTREE_API_VERSION = 385
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v385 -> 2025-08-15 : https://github.com/inventree/InvenTree/pull/10174
- Adjust return type of PurchaseOrderReceive API serializer
- Now returns list of of the created stock items when receiving
v384 -> 2025-08-08 : https://github.com/inventree/InvenTree/pull/9969
- Bump allauth

View File

@@ -2,6 +2,7 @@
from datetime import datetime
from string import Formatter
from typing import Optional
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
@@ -1154,12 +1155,16 @@ class InvenTreeBarcodeMixin(models.Model):
return self.format_barcode()
@classmethod
def lookup_barcode(cls, barcode_hash):
def lookup_barcode(cls, barcode_hash: str) -> models.Model:
"""Check if a model instance exists with the specified third-party barcode hash."""
return cls.objects.filter(barcode_hash=barcode_hash).first()
def assign_barcode(
self, barcode_hash=None, barcode_data=None, raise_error=True, save=True
self,
barcode_hash: Optional[str] = None,
barcode_data: Optional[str] = None,
raise_error: bool = True,
save: bool = True,
):
"""Assign an external (third-party) barcode to this object."""
# Must provide either barcode_hash or barcode_data

View File

@@ -28,7 +28,10 @@ from plugin.models import PluginConfig
@contextmanager
def count_queries(
msg: Optional[str] = None, log_to_file: bool = False, using: str = 'default'
msg: Optional[str] = None,
log_to_file: bool = False,
using: str = 'default',
threshold: int = 10,
): # pragma: no cover
"""Helper function to count the number of queries executed.
@@ -36,10 +39,15 @@ def count_queries(
msg: Optional message to print after counting queries
log_to_file: If True, log the queries to a file (default = False)
using: The database connection to use (default = 'default')
threshold: Minimum number of queries to log (default = 10)
"""
t1 = time.time()
with CaptureQueriesContext(connections[using]) as context:
yield
dt = time.time() - t1
n = len(context.captured_queries)
if log_to_file:
@@ -47,10 +55,13 @@ def count_queries(
for q in context.captured_queries:
f.write(str(q['sql']) + '\n\n')
if msg:
print(f'{msg}: Executed {n} queries')
else:
print(f'Executed {n} queries')
output = f'Executed {n} queries in {dt:.4f}s'
if threshold and n >= threshold:
if msg:
print(f'{msg}: {output}')
else:
print(output)
def addUserPermission(user: User, app_name: str, model_name: str, perm: str) -> None:

View File

@@ -6,7 +6,7 @@ from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import F, Q, Sum
from django.db.models import F, Q, QuerySet, Sum
from django.db.models.functions import Coalesce
from django.db.models.signals import post_save
from django.dispatch.dispatcher import receiver
@@ -870,7 +870,7 @@ class Build(
allocations.delete()
@transaction.atomic
def create_build_output(self, quantity, **kwargs) -> list[stock.models.StockItem]:
def create_build_output(self, quantity, **kwargs) -> QuerySet:
"""Create a new build output against this BuildOrder.
Arguments:
@@ -883,7 +883,7 @@ class Build(
auto_allocate: Automatically allocate stock with matching serial numbers
Returns:
A list of the created output (StockItem) objects.
A QuerySet of the created output (StockItem) objects.
"""
trackable_parts = self.part.get_trackable_parts()

View File

@@ -15,7 +15,7 @@ import rest_framework.serializers
from django_filters import rest_framework as rest_filters
from django_ical.views import ICalFeed
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework import status
from rest_framework.response import Response
@@ -24,6 +24,7 @@ import common.models
import common.settings
import company.models
import stock.models as stock_models
import stock.serializers as stock_serializers
from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
@@ -472,6 +473,7 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, CreateAPI):
serializer_class = serializers.PurchaseOrderIssueSerializer
@extend_schema(responses={201: stock_serializers.StockItemSerializer(many=True)})
class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI):
"""API endpoint to receive stock items against a PurchaseOrder.
@@ -489,9 +491,18 @@ class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI):
"""
queryset = models.PurchaseOrderLineItem.objects.none()
serializer_class = serializers.PurchaseOrderReceiveSerializer
def create(self, request, *args, **kwargs):
"""Override the create method to handle stock item creation."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
items = serializer.save()
queryset = stock_serializers.StockItemSerializer.annotate_queryset(items)
response = stock_serializers.StockItemSerializer(queryset, many=True)
return Response(response.data, status=status.HTTP_201_CREATED)
class PurchaseOrderLineItemFilter(LineItemFilter):
"""Custom filters for the PurchaseOrderLineItemList endpoint."""

View File

@@ -7,7 +7,7 @@ from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import F, Q, Sum
from django.db.models import F, Q, QuerySet, Sum
from django.db.models.functions import Coalesce
from django.db.models.signals import post_save
from django.dispatch.dispatcher import receiver
@@ -782,10 +782,16 @@ class PurchaseOrder(TotalPriceMixin, Order):
self.save()
unique_parts = set()
# Schedule pricing update for any referenced parts
for line in self.lines.all():
for line in self.lines.all().prefetch_related('part__part'):
# Ensure we only check 'unique' parts
if line.part and line.part.part:
line.part.part.schedule_pricing_update(create=True)
unique_parts.add(line.part.part)
for part in unique_parts:
part.schedule_pricing_update(create=True, refresh=False)
trigger_event(PurchaseOrderEvents.COMPLETED, id=self.pk)
@@ -915,6 +921,263 @@ class PurchaseOrder(TotalPriceMixin, Order):
"""Return True if all line items have been received."""
return self.pending_line_items().count() == 0
@transaction.atomic
def receive_line_items(
self, location, items: list, user: User, **kwargs
) -> QuerySet:
"""Receive multiple line items against this PurchaseOrder.
Arguments:
location: The StockLocation to receive the items into
items: A list of line item IDs and quantities to receive
user: The User performing the action
Returns:
A QuerySet of the newly created StockItem objects
The 'items' list values contain:
line_item: The PurchaseOrderLineItem instance
quantity: The quantity of items to receive
location: The location to receive the item into (optional)
status: The 'status' of the item
barcode: Optional barcode for the item (optional)
batch_code: Optional batch code for the item (optional)
expiry_date: Optional expiry date for the item (optional)
serials: Optional list of serial numbers (optional)
notes: Optional notes for the item (optional)
"""
if self.status != PurchaseOrderStatus.PLACED:
raise ValidationError(
"Lines can only be received against an order marked as 'PLACED'"
)
# List of stock items which have been created
stock_items: list[stock.models.StockItem] = []
# List of stock items to bulk create
bulk_create_items: list[stock.models.StockItem] = []
# List of tracking entries to create
tracking_entries: list[stock.models.StockItemTracking] = []
# List of line items to update
line_items_to_update: list[PurchaseOrderLineItem] = []
convert_purchase_price = get_global_setting('PURCHASEORDER_CONVERT_CURRENCY')
default_currency = currency_code_default()
# Prefetch line item objects for DB efficiency
line_items_ids = [item['line_item'].pk for item in items]
line_items = PurchaseOrderLineItem.objects.filter(
pk__in=line_items_ids
).prefetch_related('part', 'part__part', 'order')
# Map order line items to their corresponding stock items
line_item_map = {line.pk: line for line in line_items}
# Before we continue, validate that each line item is valid
# We validate this here because it is far more efficient,
# after we have fetched *all* line itemes in a single DB query
for line_item in line_item_map.values():
if line_item.order != self:
raise ValidationError({_('Line item does not match purchase order')})
if not line_item.part or not line_item.part.part:
raise ValidationError({_('Line item is missing a linked part')})
for item in items:
# Extract required information
line_item_id = item['line_item'].pk
line = line_item_map[line_item_id]
quantity = item['quantity']
barcode = item.get('barcode', '')
try:
if quantity < 0:
raise ValidationError({
'quantity': _('Quantity must be a positive number')
})
quantity = InvenTree.helpers.clean_decimal(quantity)
except TypeError:
raise ValidationError({'quantity': _('Invalid quantity provided')})
supplier_part = line.part
if not supplier_part:
logger.warning(
'Line item %s is missing a linked supplier part', line.pk
)
continue
base_part = supplier_part.part
stock_location = item.get('location', location) or line.get_destination()
# Calculate the received quantity in base part units
stock_quantity = supplier_part.base_quantity(quantity)
# Calculate unit purchase price (in base units)
if line.purchase_price:
purchase_price = line.purchase_price / supplier_part.base_quantity(1)
if convert_purchase_price:
purchase_price = convert_money(purchase_price, default_currency)
else:
purchase_price = None
# Extract optional serial numbers
serials = item.get('serials', None)
if serials and type(serials) is list and len(serials) > 0:
serialize = True
else:
serialize = False
serials = [None]
# Construct dataset for creating a new StockItem instances
stock_data = {
'part': supplier_part.part,
'supplier_part': supplier_part,
'purchase_order': self,
'purchase_price': purchase_price,
'status': item.get('status', StockStatus.OK.value),
'location': stock_location,
'quantity': 1 if serialize else stock_quantity,
'batch': item.get('batch_code', ''),
'expiry_date': item.get('expiry_date', None),
'packaging': item.get('packaging') or supplier_part.packaging,
}
# Check linked build order
# This is for receiving against an *external* build order
if build_order := line.build_order:
if not build_order.external:
raise ValidationError(
'Cannot receive items against an internal build order'
)
if build_order.part != base_part:
raise ValidationError(
'Cannot receive items against a build order for a different part'
)
if not stock_location and build_order.destination:
# Override with the build order destination (if not specified)
stock_data['location'] = stock_location = build_order.destination
if build_order.active:
# An 'active' build order marks the items as "in production"
stock_data['build'] = build_order
stock_data['is_building'] = True
elif build_order.status == BuildStatus.COMPLETE:
# A 'completed' build order marks the items as "completed"
stock_data['build'] = build_order
stock_data['is_building'] = False
# Increase the 'completed' quantity for the build order
build_order.completed += stock_quantity
build_order.save()
elif build_order.status == BuildStatus.CANCELLED:
# A 'cancelled' build order is ignored
pass
else:
# Un-handled state - raise an error
raise ValidationError(
"Cannot receive items against a build order in state '{build_order.status}'"
)
# Now, create the new stock items
if serialize:
stock_items.extend(
stock.models.StockItem._create_serial_numbers(
serials=serials, **stock_data
)
)
else:
new_item = stock.models.StockItem(
**stock_data,
serial='',
tree_id=stock.models.StockItem.getNextTreeID(),
parent=None,
level=0,
lft=1,
rght=2,
)
if barcode:
new_item.assign_barcode(barcode_data=barcode, save=False)
# new_item.save()
bulk_create_items.append(new_item)
# Update the line item quantity
line.received += quantity
line_items_to_update.append(line)
# Bulk create new stock items
if len(bulk_create_items) > 0:
stock.models.StockItem.objects.bulk_create(bulk_create_items)
# Fetch them back again
tree_ids = [item.tree_id for item in bulk_create_items]
created_items = stock.models.StockItem.objects.filter(
tree_id__in=tree_ids, level=0, lft=1, rght=2, purchase_order=self
).prefetch_related('location')
stock_items.extend(created_items)
# Generate a new tracking entry for each stock item
for item in stock_items:
tracking_entries.append(
item.add_tracking_entry(
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
user,
location=item.location,
purchaseorder=self,
quantity=float(item.quantity),
commit=False,
)
)
# Bulk create new tracking entries for each item
stock.models.StockItemTracking.objects.bulk_create(tracking_entries)
# Update received quantity for each line item
PurchaseOrderLineItem.objects.bulk_update(line_items_to_update, ['received'])
# Trigger an event for any interested plugins
trigger_event(
PurchaseOrderEvents.ITEM_RECEIVED,
order_id=self.pk,
item_ids=[item.pk for item in stock_items],
)
# Check to auto-complete the PurchaseOrder
if (
get_global_setting('PURCHASEORDER_AUTO_COMPLETE', True)
and self.pending_line_count == 0
):
self.received_by = user
self.complete_order()
# Send notification
notify_responsible(
self,
PurchaseOrder,
exclude=user,
content=InvenTreeNotificationBodies.ItemsReceived,
extra_users=line.part.part.get_subscribers(),
)
# Return a list of the created stock items
return stock.models.StockItem.objects.filter(
pk__in=[item.pk for item in stock_items]
)
@transaction.atomic
def receive_line_item(
self, line, location, quantity, user, status=StockStatus.OK.value, **kwargs
@@ -934,182 +1197,24 @@ class PurchaseOrder(TotalPriceMixin, Order):
notes: Optional notes field for the StockItem
packaging: Optional packaging field for the StockItem
barcode: Optional barcode field for the StockItem
notify: If true, notify users of received items
Raises:
ValidationError: If the quantity is negative or otherwise invalid
ValidationError: If the order is not in the 'PLACED' state
"""
# Extract optional batch code for the new stock item
batch_code = kwargs.get('batch_code', '')
# Extract optional expiry date for the new stock item
expiry_date = kwargs.get('expiry_date')
# Extract optional list of serial numbers
serials = kwargs.get('serials')
# Extract optional notes field
notes = kwargs.get('notes', '')
# Extract optional packaging field
packaging = kwargs.get('packaging')
if not packaging:
# Default to the packaging field for the linked supplier part
if line.part:
packaging = line.part.packaging
# Extract optional barcode field
barcode = kwargs.get('barcode')
# Prevent null values for barcode
if barcode is None:
barcode = ''
if self.status != PurchaseOrderStatus.PLACED:
raise ValidationError(
"Lines can only be received against an order marked as 'PLACED'"
)
try:
if quantity < 0:
raise ValidationError({
'quantity': _('Quantity must be a positive number')
})
quantity = InvenTree.helpers.clean_decimal(quantity)
except TypeError:
raise ValidationError({'quantity': _('Invalid quantity provided')})
# Create a new stock item
if line.part and quantity > 0:
# Calculate received quantity in base units
stock_quantity = line.part.base_quantity(quantity)
# Calculate unit purchase price (in base units)
if line.purchase_price:
unit_purchase_price = line.purchase_price
# Convert purchase price to base units
unit_purchase_price /= line.part.base_quantity(1)
# Convert to base currency
if get_global_setting('PURCHASEORDER_CONVERT_CURRENCY'):
try:
unit_purchase_price = convert_money(
unit_purchase_price, currency_code_default()
)
except Exception:
log_error('PurchaseOrder.receive_line_item')
else:
unit_purchase_price = None
# Determine if we should individually serialize the items, or not
if type(serials) is list and len(serials) > 0:
serialize = True
else:
serialize = False
serials = [None]
# Construct dataset for receiving items
data = {
'part': line.part.part,
'supplier_part': line.part,
'location': location,
'quantity': 1 if serialize else stock_quantity,
'purchase_order': self,
'status': status,
'batch': batch_code,
'expiry_date': expiry_date,
'packaging': packaging,
'purchase_price': unit_purchase_price,
}
if build_order := line.build_order:
# Receiving items against an "external" build order
if not build_order.external:
raise ValidationError(
'Cannot receive items against an internal build order'
)
if build_order.part != data['part']:
raise ValidationError(
'Cannot receive items against a build order for a different part'
)
if not location and build_order.destination:
# Override with the build order destination (if not specified)
data['location'] = location = build_order.destination
if build_order.active:
# An 'active' build order marks the items as "in production"
data['build'] = build_order
data['is_building'] = True
elif build_order.status == BuildStatus.COMPLETE:
# A 'completed' build order marks the items as "completed"
data['build'] = build_order
data['is_building'] = False
# Increase the 'completed' quantity for the build order
build_order.completed += stock_quantity
build_order.save()
elif build_order.status == BuildStatus.CANCELLED:
# A 'cancelled' build order is ignored
pass
else:
# Un-handled state - raise an error
raise ValidationError(
"Cannot receive items against a build order in state '{build_order.status}'"
)
for sn in serials:
item = stock.models.StockItem(serial=sn, **data)
# Assign the provided barcode
if barcode:
item.assign_barcode(barcode_data=barcode, save=False)
item.save(add_note=False)
tracking_info = {'status': status, 'purchaseorder': self.pk}
item.add_tracking_entry(
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
user,
notes=notes,
deltas=tracking_info,
location=location,
purchaseorder=self,
quantity=float(quantity),
)
trigger_event(
PurchaseOrderEvents.ITEM_RECEIVED,
order_id=self.pk,
item_id=item.pk,
line_id=line.pk,
)
# Update the number of parts received against the particular line item
# Note that this quantity does *not* take the pack_quantity into account, it is "number of packs"
line.received += quantity
line.save()
# Has this order been completed?
if len(self.pending_line_items()) == 0:
if get_global_setting('PURCHASEORDER_AUTO_COMPLETE', True):
self.received_by = user
self.complete_order() # This will save the model
# Issue a notification to interested parties, that this order has been "updated"
notify_responsible(
self,
PurchaseOrder,
exclude=user,
content=InvenTreeNotificationBodies.ItemsReceived,
extra_users=line.part.part.get_subscribers(),
self.receive_line_items(
location,
[
{
'line_item': line,
'quantity': quantity,
'location': location,
'status': status,
**kwargs,
}
],
user,
)
@@ -1582,8 +1687,11 @@ class OrderLineItem(InvenTree.models.InvenTreeMetadataModel):
'reference': _('The order is locked and cannot be modified')
})
update_order = kwargs.pop('update_order', True)
super().save(*args, **kwargs)
self.order.save()
if update_order and self.order:
self.order.save()
def delete(self, *args, **kwargs):
"""Custom delete method for the OrderLineItem model.

View File

@@ -755,19 +755,11 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.PurchaseOrderLineItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Line Item'),
)
def validate_line_item(self, item):
"""Validation for the 'line_item' field."""
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,
@@ -864,19 +856,12 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
quantity = data['quantity']
serial_numbers = data.get('serial_numbers', '').strip()
base_part = line_item.part.part
base_quantity = line_item.part.base_quantity(quantity)
# Does the quantity need to be "integer" (for trackable parts?)
if base_part.trackable and Decimal(base_quantity) != int(base_quantity):
raise ValidationError({
'quantity': _(
'An integer quantity must be provided for trackable parts'
)
})
# If serial numbers are provided
if serial_numbers:
supplier_part = line_item.part
base_part = supplier_part.part
base_quantity = supplier_part.base_quantity(quantity)
try:
# Pass the serial numbers through to the parent serializer once validated
data['serials'] = extract_serial_numbers(
@@ -940,6 +925,9 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
if len(items) == 0:
raise ValidationError(_('Line items must be provided'))
# Ensure barcodes are unique
unique_barcodes = set()
# Check if the location is not specified for any particular item
for item in items:
line = item['line_item']
@@ -957,10 +945,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
'location': _('Destination location must be specified')
})
# Ensure barcodes are unique
unique_barcodes = set()
for item in items:
barcode = item.get('barcode', '')
if barcode:
@@ -971,7 +955,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
return data
def save(self):
def save(self) -> list[stock.models.StockItem]:
"""Perform the actual database transaction to receive purchase order items."""
data = self.validated_data
@@ -983,33 +967,16 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
# Location can be provided, or default to the order destination
location = data.get('location', order.destination)
# Now we can actually receive the items into stock
with transaction.atomic():
for item in items:
# Select location (in descending order of priority)
loc = (
item.get('location', None)
or location
or item['line_item'].get_destination()
)
try:
items = order.receive_line_items(
location, items, request.user if request else None
)
except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors
raise ValidationError(detail=serializers.as_serializer_error(exc))
try:
order.receive_line_item(
item['line_item'],
loc,
item['quantity'],
request.user if request else None,
status=item['status'],
barcode=item.get('barcode', ''),
batch_code=item.get('batch_code', ''),
expiry_date=item.get('expiry_date', None),
packaging=item.get('packaging', ''),
serials=item.get('serials', None),
notes=item.get('note', None),
)
except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors
raise ValidationError(detail=serializers.as_serializer_error(exc))
# Returns a list of the created items
return items
@register_importer()

View File

@@ -28,7 +28,7 @@ from order.status_codes import (
SalesOrderStatusGroups,
)
from part.models import Part
from stock.models import StockItem
from stock.models import StockItem, StockLocation
from stock.status_codes import StockStatus
from users.models import Owner
@@ -1208,6 +1208,60 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(item.quantity, 10)
self.assertEqual(item.batch, 'B-xyz-789')
def test_receive_large_quantity(self):
"""Test receipt of a large number of items."""
sp = SupplierPart.objects.first()
# Create a new order
po = models.PurchaseOrder.objects.create(
reference='PO-9999', supplier=sp.supplier
)
N_LINES = 250
# Create some line items
models.PurchaseOrderLineItem.objects.bulk_create([
models.PurchaseOrderLineItem(order=po, part=sp, quantity=1000 + i)
for i in range(N_LINES)
])
# Place the order
po.place_order()
url = reverse('api-po-receive', kwargs={'pk': po.pk})
lines = po.lines.all()
location = StockLocation.objects.filter(structural=False).first()
N_ITEMS = StockItem.objects.count()
# Receive all items in a single request
response = self.post(
url,
{
'items': [
{'line_item': line.pk, 'quantity': line.quantity} for line in lines
],
'location': location.pk,
},
max_query_count=100 + 2 * N_LINES,
).data
# Check for expected response
self.assertEqual(len(response), N_LINES)
self.assertEqual(N_ITEMS + N_LINES, StockItem.objects.count())
for item in response:
self.assertEqual(item['purchase_order'], po.pk)
# Check that the order has been completed
po.refresh_from_db()
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
for line in lines:
line.refresh_from_db()
self.assertEqual(line.received, line.quantity)
def test_packaging(self):
"""Test that we can supply a 'packaging' value when receiving items."""
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)

View File

@@ -258,6 +258,7 @@ class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase):
order.receive_line_item(line, loc, 50, user=None)
line.refresh_from_db()
self.assertEqual(line.remaining(), 50)
self.assertEqual(part.on_order, 1350)
@@ -348,6 +349,9 @@ class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase):
# Receive 5x item against line_2
po.receive_line_item(line_2, loc, 5, user=None)
line_1.refresh_from_db()
line_2.refresh_from_db()
# Check that the line items have been updated correctly
self.assertEqual(line_1.quantity, 3)
self.assertEqual(line_1.received, 1)

View File

@@ -2059,7 +2059,9 @@ class Part(
return pricing
def schedule_pricing_update(self, create: bool = False, force: bool = False):
def schedule_pricing_update(
self, create: bool = False, force: bool = False, refresh: bool = True
):
"""Helper function to schedule a pricing update.
Importantly, catches any errors which may occur during deletion of related objects,
@@ -2070,22 +2072,24 @@ class Part(
Arguments:
create: Whether or not a new PartPricing object should be created if it does not already exist
force: If True, force the pricing to be updated even auto pricing is disabled
refresh: If True, refresh the PartPricing object from the database
"""
if not force and not get_global_setting(
'PRICING_AUTO_UPDATE', backup_value=True
):
return
try:
self.refresh_from_db()
except Part.DoesNotExist:
return
if refresh:
try:
self.refresh_from_db()
except Part.DoesNotExist:
return
try:
pricing = self.pricing
if create or pricing.pk:
pricing.schedule_for_update()
pricing.schedule_for_update(refresh=refresh)
except IntegrityError:
# If this part instance has been deleted,
# some post-delete or post-save signals may still be fired
@@ -2731,11 +2735,12 @@ class PartPricing(common.models.MetaMixin):
return result
def schedule_for_update(self, counter: int = 0):
def schedule_for_update(self, counter: int = 0, refresh: bool = True):
"""Schedule this pricing to be updated.
Arguments:
counter: Recursion counter (used to prevent infinite recursion)
refresh: If specified, the PartPricing object will be refreshed from the database
"""
import InvenTree.ready
@@ -2758,7 +2763,7 @@ class PartPricing(common.models.MetaMixin):
return
try:
if self.pk:
if refresh and self.pk:
self.refresh_from_db()
except (PartPricing.DoesNotExist, IntegrityError):
# Error thrown if this PartPricing instance has already been removed
@@ -2770,7 +2775,8 @@ class PartPricing(common.models.MetaMixin):
# Ensure that the referenced part still exists in the database
try:
p = self.part
p.refresh_from_db()
if True: # refresh and p.pk:
p.refresh_from_db()
except IntegrityError:
logger.exception(
"Could not update PartPricing as Part '%s' does not exist", self.part

View File

@@ -275,6 +275,8 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
def test_api_call(self):
"""Test that api calls work."""
import time
# api_call
result = self.mixin.get_external_url()
self.assertTrue(result)
@@ -290,13 +292,22 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
# Set API TOKEN
self.mixin.set_setting('API_TOKEN', 'reqres-free-v1')
# api_call with post and data
result = self.mixin.api_call(
'https://reqres.in/api/users/',
json={'name': 'morpheus', 'job': 'leader'},
method='POST',
endpoint_is_url=True,
timeout=5000,
)
# Try multiple times, account for the rate limit
result = None
for _ in range(5):
try:
result = self.mixin.api_call(
'https://reqres.in/api/users/',
json={'name': 'morpheus', 'job': 'leader'},
method='POST',
endpoint_is_url=True,
timeout=5000,
)
break
except Exception:
time.sleep(1)
self.assertTrue(result)
self.assertNotIn('error', result)

View File

@@ -297,17 +297,25 @@ class PluginsRegistry:
active (bool, optional): Filter by 'active' status of plugin. Defaults to True.
builtin (bool, optional): Filter by 'builtin' status of plugin. Defaults to None.
"""
try:
# Pre-fetch the PluginConfig objects to avoid multiple database queries
from plugin.models import PluginConfig
# We can store the PluginConfig objects against the session cache,
# which allows us to avoid hitting the database multiple times (per session)
# As we have already checked the registry hash, this is a valid cache key
cache_key = f'plugin_configs:{self.registry_hash}'
plugin_configs = PluginConfig.objects.all()
configs = InvenTree.cache.get_session_cache(cache_key)
configs = {config.key: config for config in plugin_configs}
except (ProgrammingError, OperationalError):
# The database is not ready yet
logger.warning('plugin.registry.with_mixin: Database not ready')
return []
if not configs:
try:
# Pre-fetch the PluginConfig objects to avoid multiple database queries
from plugin.models import PluginConfig
plugin_configs = PluginConfig.objects.all()
configs = {config.key: config for config in plugin_configs}
InvenTree.cache.set_session_cache(cache_key, configs)
except (ProgrammingError, OperationalError):
# The database is not ready yet
logger.warning('plugin.registry.with_mixin: Database not ready')
return []
mixin = str(mixin).lower().strip()

View File

@@ -10,12 +10,23 @@ class SampleApiCallerPluginTests(TestCase):
def test_return(self):
"""Check if the external api call works."""
import time
# The plugin should be defined
self.assertIn('sample-api-caller', registry.plugins)
plg = registry.plugins['sample-api-caller']
self.assertTrue(plg)
# do an api call
result = plg.get_external_url()
# Note: rate limits may apply in CI
result = False
for _i in range(5):
result = plg.get_external_url()
if result:
break
else:
time.sleep(1)
self.assertTrue(result)
self.assertIn('data', result)

View File

@@ -627,7 +627,11 @@ class StockItem(
for serial in serials:
data['serial'] = serial
data['serial_int'] = StockItem.convert_serial_to_int(serial)
if serial is not None:
data['serial_int'] = StockItem.convert_serial_to_int(serial) or 0
else:
data['serial_int'] = 0
data['tree_id'] = tree_id
@@ -704,6 +708,10 @@ class StockItem(
"""
serial = str(getattr(self, 'serial', '')).strip()
if not serial:
self.serial_int = 0
return
serial_int = self.convert_serial_to_int(serial)
try:

View File

@@ -364,7 +364,6 @@ export function RelatedModelField({
options={data}
filterOption={null}
onInputChange={(value: any) => {
console.log('onInputChange', value);
setValue(value);
}}
onChange={onChange}

View File

@@ -1,4 +1,5 @@
import { t } from '@lingui/core/macro';
import { Text } from '@mantine/core';
import type { ReactNode } from 'react';
import { ModelType } from '@lib/enums/ModelType';
@@ -118,7 +119,7 @@ export function RenderSalesOrderShipment({
return (
<RenderInlineModel
primary={order.reference}
secondary={`${t`Shipment`} ${instance.reference}`}
suffix={<Text size='sm'>{`${t`Shipment`} ${instance.reference}`}</Text>}
/>
);
}

View File

@@ -63,7 +63,7 @@ export function RenderPartCategory(
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard
value=''
value={<Text size='sm'>{instance.description}</Text>}
position='bottom-end'
zIndex={10000}
icon='sitemap'
@@ -89,7 +89,6 @@ export function RenderPartCategory(
</>
}
primary={category}
secondary={instance.description}
suffix={suffix}
url={
props.link

View File

@@ -25,7 +25,7 @@ export function RenderStockLocation(
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard
value=''
value={<Text size='sm'>{instance.description}</Text>}
position='bottom-end'
zIndex={10000}
icon='sitemap'
@@ -51,7 +51,6 @@ export function RenderStockLocation(
</>
}
primary={location}
secondary={instance.description}
suffix={suffix}
url={
props.link

View File

@@ -843,13 +843,6 @@ export default function PartDetail() {
<Skeleton />
)
},
{
name: 'builds',
label: t`Build Orders`,
icon: <IconTools />,
hidden: !part.assembly || !user.hasViewRole(UserRoles.build),
content: part.pk ? <BuildOrderTable partId={part.pk} /> : <Skeleton />
},
{
name: 'used_in',
label: t`Used In`,
@@ -905,6 +898,13 @@ export default function PartDetail() {
!globalSettings.isSet('RETURNORDER_ENABLED'),
content: part.pk ? <ReturnOrderTable partId={part.pk} /> : <Skeleton />
},
{
name: 'builds',
label: t`Build Orders`,
icon: <IconTools />,
hidden: !part.assembly || !user.hasViewRole(UserRoles.build),
content: part.pk ? <BuildOrderTable partId={part.pk} /> : <Skeleton />
},
{
name: 'stocktake',
label: t`Stock History`,

View File

@@ -247,38 +247,42 @@ export default function InvenTreeTableHeader({
variant='transparent'
aria-label='table-select-filters'
>
<Tooltip label={t`Table Filters`} position='top-end'>
<HoverCard
position='bottom-end'
withinPortal={true}
disabled={!tableState.filterSet.activeFilters?.length}
>
<HoverCard.Target>
<HoverCard
position='bottom-end'
withinPortal={true}
disabled={!tableState.filterSet.activeFilters?.length}
>
<HoverCard.Target>
<Tooltip
label={t`Table Filters`}
position='top-end'
disabled={!!tableState.filterSet.activeFilters?.length}
>
<IconFilter
onClick={() => setFiltersVisible(!filtersVisible)}
/>
</HoverCard.Target>
<HoverCard.Dropdown>
<Paper p='sm' withBorder>
<Stack gap='xs'>
<StylishText size='md'>{t`Active Filters`}</StylishText>
<Divider />
{tableState.filterSet.activeFilters?.map((filter) => (
<Group
key={filter.name}
justify='space-between'
gap='xl'
wrap='nowrap'
>
<Text size='sm'>{filter.label}</Text>
<Text size='xs'>{filter.displayValue}</Text>
</Group>
))}
</Stack>
</Paper>
</HoverCard.Dropdown>
</HoverCard>
</Tooltip>
</Tooltip>
</HoverCard.Target>
<HoverCard.Dropdown>
<Paper p='sm' withBorder>
<Stack gap='xs'>
<StylishText size='md'>{t`Active Filters`}</StylishText>
<Divider />
{tableState.filterSet.activeFilters?.map((filter) => (
<Group
key={filter.name}
justify='space-between'
gap='xl'
wrap='nowrap'
>
<Text size='sm'>{filter.label}</Text>
<Text size='xs'>{filter.displayValue}</Text>
</Group>
))}
</Stack>
</Paper>
</HoverCard.Dropdown>
</HoverCard>
</ActionIcon>
</Indicator>
)}

View File

@@ -616,11 +616,7 @@ test('Parts - Bulk Edit', async ({ browser }) => {
await page.getByLabel('action-menu-part-actions').click();
await page.getByLabel('action-menu-part-actions-set-category').click();
await page.getByLabel('related-field-category').fill('rnitu');
await page
.getByRole('option', { name: '- Furniture/Chairs' })
.getByRole('paragraph')
.click();
await page.getByRole('option', { name: '- Furniture/Chairs' }).click;
await page.getByRole('button', { name: 'Update' }).click();
await page.getByText('Items Updated').waitFor();
});