Fix 500 errors for transitions (#12678)

This commit is contained in:
Oliver
2026-08-22 00:51:51 +10:00
committed by GitHub
parent 6ca7160723
commit a435738507
4 changed files with 253 additions and 15 deletions
+15
View File
@@ -742,10 +742,25 @@ class BuildOrderContextMixin:
try: try:
ctx['build'] = self.get_build() ctx['build'] = self.get_build()
except NotFound: except NotFound:
# Swallowed here (e.g. schema generation may call this without a
# resolvable pk) - create() below is what actually enforces a 404
# for a real request against a non-existent build.
pass pass
return ctx return ctx
def create(self, request, *args, **kwargs):
"""Ensure the target Build actually exists before attempting the action.
Without this, a POST against a non-existent pk would fall through to the
action serializer's save(), which unconditionally reads
self.context['build'] - raising an unhandled KeyError (HTTP 500) instead of
the intended 404.
"""
self.get_build()
return super().create(request, *args, **kwargs)
@extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)}) @extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)})
class BuildOutputCreate(BuildOrderContextMixin, CreateAPI): class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
+43
View File
@@ -3033,3 +3033,46 @@ class BuildAutoAllocateAPITest(InvenTreeAPITestCase):
self.assertEqual(fa.count(), 4) self.assertEqual(fa.count(), 4)
allocated = sum(a.quantity for a in fa) allocated = sum(a.quantity for a in fa)
self.assertEqual(allocated, 130) # 130 allocated to each line self.assertEqual(allocated, 130) # 130 allocated to each line
class BuildActionMissingPkTest(InvenTreeAPITestCase):
"""Regression tests for a class of bugs in the Build action endpoints.
BuildOrderContextMixin looks up the target Build in get_serializer_context(), but
silently swallows a not-found result (needed so schema/OPTIONS introspection
doesn't break). Without an explicit check elsewhere, a POST against a
non-existent pk fell through to the action serializer's save(), which
unconditionally reads self.context['build'] - an unhandled KeyError (HTTP 500)
rather than a clean 404. Fixed by BuildOrderContextMixin.create().
"""
roles = ['build.add']
def test_actions_404_for_missing_build(self):
"""Every BuildOrderContextMixin-based action should 404, not 500, for a bad pk."""
for url_name in [
'api-build-issue',
'api-build-hold',
'api-build-cancel',
'api-build-finish',
'api-build-allocate',
]:
url = reverse(url_name, kwargs={'pk': 999999})
self.post(url, {}, expected_code=404)
def test_output_actions_already_safe(self):
"""The build-output actions already guard against a missing build themselves.
(BuildOutputScrap/Complete/Delete/AutoAllocate/Consume all call
self.get_build() explicitly at the top of a custom post() override, which
already raises NotFound correctly - this test just locks that in.)
"""
for url_name in [
'api-build-output-scrap',
'api-build-output-complete',
'api-build-output-delete',
'api-build-auto-allocate',
'api-build-consume',
]:
url = reverse(url_name, kwargs={'pk': 999999})
self.post(url, {}, expected_code=404)
+108 -15
View File
@@ -444,16 +444,33 @@ class PurchaseOrderViewSet(
queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset)
return queryset return queryset
def get_order(self):
"""Return the PurchaseOrder object associated with this API endpoint.
Note: deliberately a raw lookup rather than self.get_object() - the latter
routes through ParameterListMixin.filter_queryset(), which assumes
self.serializer_class.Meta.model exists. That's true for the default
PurchaseOrderSerializer, but not for the plain-Serializer action classes
(PurchaseOrderHoldSerializer etc.) used by hold/cancel/complete/issue/receive
below, so calling get_object() from those actions raises an unrelated
AttributeError instead of the intended 404.
"""
try:
return models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
except (ValueError, models.PurchaseOrder.DoesNotExist):
raise NotFound(_('Purchase order not found'))
def get_serializer_context(self): def get_serializer_context(self):
"""Add the PurchaseOrder object to the serializer context.""" """Add the PurchaseOrder object to the serializer context."""
context = super().get_serializer_context() context = super().get_serializer_context()
# Pass the purchase order through to the serializer for validation # Pass the purchase order through to the serializer for validation
try: try:
context['order'] = models.PurchaseOrder.objects.get( context['order'] = self.get_order()
pk=self.kwargs.get('pk', None) except NotFound:
) # Swallowed here (e.g. schema generation may call this without a
except Exception: # resolvable pk) - each action method below is what actually enforces
# a 404 for a real request against a non-existent order.
pass pass
context['request'] = self.request context['request'] = self.request
@@ -470,6 +487,12 @@ class PurchaseOrderViewSet(
) )
def hold(self, request, pk=None): def hold(self, request, pk=None):
"""API endpoint to place a PurchaseOrder on hold.""" """API endpoint to place a PurchaseOrder on hold."""
# Ensure the target order actually exists (raises NotFound -> 404 otherwise) -
# without this, a non-existent pk would fall through to the serializer's
# save(), which unconditionally reads self.context['order'], raising an
# unhandled KeyError (HTTP 500) instead of a clean 404.
self.get_order()
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
@@ -488,6 +511,8 @@ class PurchaseOrderViewSet(
The purchase order must be in a state which can be cancelled The purchase order must be in a state which can be cancelled
""" """
self.get_order()
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
@@ -503,6 +528,8 @@ class PurchaseOrderViewSet(
) )
def complete(self, request, pk=None): def complete(self, request, pk=None):
"""API endpoint to 'complete' a purchase order.""" """API endpoint to 'complete' a purchase order."""
self.get_order()
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
@@ -518,6 +545,8 @@ class PurchaseOrderViewSet(
) )
def issue(self, request, pk=None): def issue(self, request, pk=None):
"""API endpoint to 'issue' (place) a PurchaseOrder.""" """API endpoint to 'issue' (place) a PurchaseOrder."""
self.get_order()
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
@@ -534,6 +563,8 @@ class PurchaseOrderViewSet(
) )
def receive(self, request, pk=None): def receive(self, request, pk=None):
"""API endpoint to receive stock items against a PurchaseOrder.""" """API endpoint to receive stock items against a PurchaseOrder."""
self.get_order()
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
items = serializer.save() items = serializer.save()
@@ -1159,6 +1190,13 @@ class SalesOrderContextMixin:
queryset = models.SalesOrder.objects.all() queryset = models.SalesOrder.objects.all()
def get_order(self):
"""Return the SalesOrder object associated with this API endpoint."""
try:
return models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
except (ValueError, models.SalesOrder.DoesNotExist):
raise NotFound(_('Sales order not found'))
def get_serializer_context(self): def get_serializer_context(self):
"""Add the 'order' reference to the serializer context for any classes which inherit this mixin.""" """Add the 'order' reference to the serializer context for any classes which inherit this mixin."""
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
@@ -1166,12 +1204,27 @@ class SalesOrderContextMixin:
ctx['request'] = self.request ctx['request'] = self.request
try: try:
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None)) ctx['order'] = self.get_order()
except Exception: except NotFound:
# Swallowed here (e.g. schema generation may call this without a
# resolvable pk) - create() below is what actually enforces a 404
# for a real request against a non-existent order.
pass pass
return ctx return ctx
def create(self, request, *args, **kwargs):
"""Ensure the target SalesOrder actually exists before attempting the action.
Without this, a POST against a non-existent pk would fall through to the
action serializer's save(), which unconditionally reads
self.context['order'] - raising an unhandled KeyError (HTTP 500) instead of
the intended 404.
"""
self.get_order()
return super().create(request, *args, **kwargs)
class SalesOrderHold(SalesOrderContextMixin, CreateAPI): class SalesOrderHold(SalesOrderContextMixin, CreateAPI):
"""API endpoint to place a SalesOrder on hold.""" """API endpoint to place a SalesOrder on hold."""
@@ -1716,22 +1769,42 @@ class ReturnOrderContextMixin:
queryset = models.ReturnOrder.objects.all() queryset = models.ReturnOrder.objects.all()
def get_order(self):
"""Return the ReturnOrder object associated with this API endpoint."""
try:
return models.ReturnOrder.objects.get(pk=self.kwargs.get('pk', None))
except (ValueError, models.ReturnOrder.DoesNotExist):
raise NotFound(_('Return order not found'))
def get_serializer_context(self): def get_serializer_context(self):
"""Add the PurchaseOrder object to the serializer context.""" """Add the ReturnOrder object to the serializer context."""
context = super().get_serializer_context() context = super().get_serializer_context()
# Pass the ReturnOrder instance through to the serializer for validation # Pass the ReturnOrder instance through to the serializer for validation
try: try:
context['order'] = models.ReturnOrder.objects.get( context['order'] = self.get_order()
pk=self.kwargs.get('pk', None) except NotFound:
) # Swallowed here (e.g. schema generation may call this without a
except Exception: # resolvable pk) - create() below is what actually enforces a 404
# for a real request against a non-existent order.
pass pass
context['request'] = self.request context['request'] = self.request
return context return context
def create(self, request, *args, **kwargs):
"""Ensure the target ReturnOrder actually exists before attempting the action.
Without this, a POST against a non-existent pk would fall through to the
action serializer's save(), which unconditionally reads
self.context['order'] - raising an unhandled KeyError (HTTP 500) instead of
the intended 404.
"""
self.get_order()
return super().create(request, *args, **kwargs)
class ReturnOrderCancel(ReturnOrderContextMixin, CreateAPI): class ReturnOrderCancel(ReturnOrderContextMixin, CreateAPI):
"""API endpoint to cancel a ReturnOrder.""" """API endpoint to cancel a ReturnOrder."""
@@ -1999,22 +2072,42 @@ class TransferOrderContextMixin:
queryset = models.TransferOrder.objects.all() queryset = models.TransferOrder.objects.all()
def get_order(self):
"""Return the TransferOrder object associated with this API endpoint."""
try:
return models.TransferOrder.objects.get(pk=self.kwargs.get('pk', None))
except (ValueError, models.TransferOrder.DoesNotExist):
raise NotFound(_('Transfer order not found'))
def get_serializer_context(self): def get_serializer_context(self):
"""Add the TransferOrder object to the serializer context.""" """Add the TransferOrder object to the serializer context."""
context = super().get_serializer_context() context = super().get_serializer_context()
# Pass the Transfer instance through to the serializer for validation # Pass the Transfer instance through to the serializer for validation
try: try:
context['order'] = models.TransferOrder.objects.get( context['order'] = self.get_order()
pk=self.kwargs.get('pk', None) except NotFound:
) # Swallowed here (e.g. schema generation may call this without a
except Exception: # resolvable pk) - create() below is what actually enforces a 404
# for a real request against a non-existent order.
pass pass
context['request'] = self.request context['request'] = self.request
return context return context
def create(self, request, *args, **kwargs):
"""Ensure the target TransferOrder actually exists before attempting the action.
Without this, a POST against a non-existent pk would fall through to the
action serializer's save(), which unconditionally reads
self.context['order'] - raising an unhandled KeyError (HTTP 500) instead of
the intended 404.
"""
self.get_order()
return super().create(request, *args, **kwargs)
class TransferOrderCancel(TransferOrderContextMixin, CreateAPI): class TransferOrderCancel(TransferOrderContextMixin, CreateAPI):
"""API endpoint to cancel a TransferOrder.""" """API endpoint to cancel a TransferOrder."""
+87
View File
@@ -5494,3 +5494,90 @@ class SalesOrderAllocationBulkDeleteAPITest(InvenTreeAPITestCase):
self.assertEqual( self.assertEqual(
SalesOrderAllocation.objects.filter(pk__in=shipped_ids).count(), 2 SalesOrderAllocation.objects.filter(pk__in=shipped_ids).count(), 2
) )
class OrderActionMissingPkTest(InvenTreeAPITestCase):
"""Regression tests for a class of bugs in the order-app action endpoints.
Each order type's *ContextMixin looks up the target order in
get_serializer_context(), but silently swallows a not-found result (needed so
schema/OPTIONS introspection doesn't break). Without an explicit check
elsewhere, a POST against a non-existent pk fell through to the action
serializer's save(), which unconditionally reads self.context['order'] - an
unhandled KeyError (HTTP 500) rather than a clean 404.
Fixed by SalesOrderContextMixin/ReturnOrderContextMixin/TransferOrderContextMixin
.create(), and (since PurchaseOrderViewSet's actions are plain ViewSet @action
methods rather than CreateAPI subclasses) an explicit check at the top of each
PurchaseOrderViewSet action method.
"""
roles = [
'purchase_order.add',
'sales_order.add',
'return_order.add',
'transfer_order.add',
]
def test_purchase_order_actions_404(self):
"""Each PurchaseOrderViewSet action should 404, not 500, for a bad pk.
Note: PurchaseOrderViewSet.get_order() is a deliberate raw lookup rather
than self.get_object() - the latter routes through
ParameterListMixin.filter_queryset(), which assumes
self.serializer_class.Meta.model exists. That's true for the default
PurchaseOrderSerializer, but not for the plain-Serializer action classes
used here, so self.get_object() would raise an unrelated AttributeError.
"""
for url_name in [
'api-po-hold',
'api-po-cancel',
'api-po-complete',
'api-po-issue',
'api-po-receive',
]:
url = reverse(url_name, kwargs={'pk': 999999})
self.post(url, {}, expected_code=404)
def test_sales_order_actions_404(self):
"""Each SalesOrderContextMixin-based action should 404, not 500, for a bad pk."""
for url_name in [
'api-so-hold',
'api-so-cancel',
'api-so-issue',
'api-so-complete',
'api-so-allocate',
'api-so-allocate-serials',
]:
url = reverse(url_name, kwargs={'pk': 999999})
self.post(url, {}, expected_code=404)
def test_sales_order_auto_allocate_already_safe(self):
"""SalesOrderAutoAllocate overrides post() and already calls get_object() itself."""
url = reverse('api-so-auto-allocate', kwargs={'pk': 999999})
self.post(url, {}, expected_code=404)
def test_return_order_actions_404(self):
"""Each ReturnOrderContextMixin-based action should 404, not 500, for a bad pk."""
for url_name in [
'api-return-order-cancel',
'api-ro-hold',
'api-return-order-complete',
'api-return-order-issue',
'api-return-order-receive',
]:
url = reverse(url_name, kwargs={'pk': 999999})
self.post(url, {}, expected_code=404)
def test_transfer_order_actions_404(self):
"""Each TransferOrderContextMixin-based action should 404, not 500, for a bad pk."""
for url_name in [
'api-transfer-order-cancel',
'api-transfer-order-hold',
'api-transfer-order-complete',
'api-transfer-order-issue',
'api-transfer-order-allocate',
'api-transfer-order-allocate-serials',
]:
url = reverse(url_name, kwargs={'pk': 999999})
self.post(url, {}, expected_code=404)