diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index c08f24289c..f94e014816 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -8,7 +8,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.http import JsonResponse +from django.http import HttpRequest, JsonResponse from django.urls import path, reverse from django.utils.translation import gettext_lazy as _ from django.views.generic.base import RedirectView @@ -780,7 +780,7 @@ class APISearchView(GenericAPIView): 'supplierpart': company.api.SupplierPartList, 'part': part.api.PartList, 'partcategory': part.api.CategoryList, - 'purchaseorder': order.api.PurchaseOrderList, + 'purchaseorder': order.api.PurchaseOrderViewSet, 'returnorder': order.api.ReturnOrderList, 'salesorder': order.api.SalesOrderList, 'salesordershipment': order.api.SalesOrderShipmentList, @@ -844,7 +844,10 @@ class APISearchView(GenericAPIView): if type(params) is not dict: continue - view = cls() + is_viewset = issubclass(cls, viewsets.GenericViewSet) or issubclass( + cls, viewsets.ViewSetMixin + ) + view = cls if is_viewset else cls() # Override regular query params with specific ones for this search request cloned_request._request.GET = params @@ -863,7 +866,17 @@ class APISearchView(GenericAPIView): continue try: - results[key] = view.list(request, *args, **kwargs).data + if is_viewset: + # use dummy request to call the list method of the viewset + req = HttpRequest() + req.method = 'GET' + req.user = request.user + req.GET = params + + list_method = cls.as_view({'get': 'list'})(req, *args, **kwargs) + else: + list_method = view.list(request, *args, **kwargs) + results[key] = list_method.data except Exception as exc: results[key] = {'error': str(exc)} diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 0da8a25fa1..06064a4f1d 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 532 +INVENTREE_API_VERSION = 533 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v533 -> 2026-08-18 : https://github.com/inventree/InvenTree/pull/12317 + - Refactors the PurchaseOrder, PurchaseOrderLineItem and PurchaseOrderExtraLine API endpoints to use DRF viewsets + v532 -> 2026-08-15 : https://github.com/inventree/InvenTree/pull/12422 - Adds "piece_count" field to the BomItem model and API endpoints (for cut-to-length parts) diff --git a/src/backend/InvenTree/InvenTree/helpers_api.py b/src/backend/InvenTree/InvenTree/helpers_api.py index 03c2bf4fda..344d5f641c 100644 --- a/src/backend/InvenTree/InvenTree/helpers_api.py +++ b/src/backend/InvenTree/InvenTree/helpers_api.py @@ -1,11 +1,30 @@ """Helpers for InvenTrees way of using drf viewset.""" from rest_framework import mixins, routers, viewsets +from rest_framework.settings import api_settings from InvenTree.api import BulkDeleteViewsetMixin +from InvenTree.mixins import CleanMixin, CleanUpdateOnlyMixin + + +class ViewSetCleanMixin: + """Mixin class which cleans inputs using nh3.""" + + def get_success_headers(self, data): + """Return the success headers for a create/update response.""" + try: + return {'Location': str(data[api_settings.URL_FIELD_NAME])} + except (TypeError, KeyError): + return {} + + +class CleanModelViewSet(CleanMixin, ViewSetCleanMixin, viewsets.ModelViewSet): + """Viewset which provides 'retrieve', 'create', 'update', 'destroy' and 'list' actions.""" class RetrieveUpdateDestroyModelViewSet( + CleanUpdateOnlyMixin, + ViewSetCleanMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, diff --git a/src/backend/InvenTree/InvenTree/mixins.py b/src/backend/InvenTree/InvenTree/mixins.py index 7d181fb995..6de3cf417c 100644 --- a/src/backend/InvenTree/InvenTree/mixins.py +++ b/src/backend/InvenTree/InvenTree/mixins.py @@ -17,14 +17,9 @@ from InvenTree.schema import schema_for_view_output_options from InvenTree.serializers import FilterableSerializerMixin -class CleanMixin: - """Model mixin class which cleans inputs using nh3.""" - - # Define a list of field names which will *not* be cleaned - SAFE_FIELDS = [] - - def create(self, request, *args, **kwargs): - """Override to clean data before processing it.""" +class CleanCreate: # noqa: D101 + def create(self, request, *args, **kwargs): # noqa: D102 + # Override to clean data before processing it serializer = self.get_serializer(data=self.clean_data(request.data)) serializer.is_valid(raise_exception=True) self.perform_create(serializer) @@ -33,8 +28,10 @@ class CleanMixin: serializer.data, status=status.HTTP_201_CREATED, headers=headers ) - def update(self, request, *args, **kwargs): - """Override to clean data before processing it.""" + +class CleanUpdate: # noqa: D101 + def update(self, request, *args, **kwargs): # noqa: D102 + # Override to clean data before processing it partial = kwargs.pop('partial', False) instance = self.get_object() serializer = self.get_serializer( @@ -50,6 +47,13 @@ class CleanMixin: return Response(serializer.data) + +class CleanBase: + """Model mixin class which cleans inputs using nh3.""" + + # Define a list of field names which will *not* be cleaned + SAFE_FIELDS = [] + def clean_string(self, field: str, data: str) -> str: """Clean / sanitize a single input string.""" cleaned = data @@ -119,6 +123,14 @@ class CleanMixin: return clean_data +class CleanMixin(CleanCreate, CleanUpdate, CleanBase): + """Model mixin class which cleans inputs using nh3.""" + + +class CleanUpdateOnlyMixin(CleanUpdate, CleanBase): + """Model mixin class which cleans inputs using nh3.""" + + class ListAPI(generics.ListAPIView): """View for list API.""" diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py index bebc5ed487..6224f4c83b 100644 --- a/src/backend/InvenTree/InvenTree/schema.py +++ b/src/backend/InvenTree/InvenTree/schema.py @@ -16,7 +16,7 @@ from drf_spectacular.utils import ( extend_schema, extend_schema_view, ) -from rest_framework import serializers +from rest_framework import serializers, viewsets from rest_framework.pagination import LimitOffsetPagination from InvenTree.permissions import OASTokenMixin @@ -358,9 +358,11 @@ def schema_for_view_output_options(view_class): ) parameters.append(param) - extended_view = extend_schema_view(get=extend_schema(parameters=parameters))( - view_class - ) + # DRF viewsets dispatch GET requests to the 'list' action, rather than a 'get' method + operation = 'list' if issubclass(view_class, viewsets.ViewSetMixin) else 'get' + extended_view = extend_schema_view(**{ + operation: extend_schema(parameters=parameters) + })(view_class) return extended_view diff --git a/src/backend/InvenTree/data_exporter/mixins.py b/src/backend/InvenTree/data_exporter/mixins.py index af1b971db8..05876bfa75 100644 --- a/src/backend/InvenTree/data_exporter/mixins.py +++ b/src/backend/InvenTree/data_exporter/mixins.py @@ -432,8 +432,8 @@ class DataExportViewMixin: # Update the output object with the exported data output.mark_complete(output=ContentFile(datafile, filename)) - def get(self, request, *args, **kwargs): - """Override the GET method to determine export options.""" + def list(self, request, *args, **kwargs): + """Override the list method to determine export options.""" from common.serializers import DataOutputSerializer # If we are not exporting data, return the default response @@ -504,4 +504,4 @@ class DataExportViewMixin: # Return a response to the frontend return Response(DataOutputSerializer(output).data, status=200) - return super().get(request, *args, **kwargs) + return super().list(request, *args, **kwargs) diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 9ca3108b85..690c1fc9e8 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -19,6 +19,7 @@ from django_ical.views import ICalFeed from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema, extend_schema_field from rest_framework import status +from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.response import Response @@ -34,6 +35,7 @@ from data_exporter.mixins import DataExportViewMixin from generic.states.api import StatusView from InvenTree.api import ( BulkDeleteMixin, + BulkDeleteViewsetMixin, BulkUpdateMixin, ListCreateDestroyAPIView, ParameterListMixin, @@ -42,6 +44,11 @@ from InvenTree.api import ( from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import SEARCH_ORDER_FILTER, InvenTreeDateFilter from InvenTree.helpers import current_date, str2bool +from InvenTree.helpers_api import ( + CleanModelViewSet, + InvenTreeApiRouter, + RetrieveUpdateDestroyModelViewSet, +) from InvenTree.helpers_model import construct_absolute_url, get_base_url from InvenTree.mixins import ( CreateAPI, @@ -65,6 +72,8 @@ from order.status_codes import ( from part.models import Part from users.models import Owner +order_router = InvenTreeApiRouter() + class GeneralExtraLineListOutputOptions(OutputConfiguration): """Output options for the GeneralExtraLineList endpoint.""" @@ -372,40 +381,29 @@ class PurchaseOrderOutputOptions(OutputConfiguration): OPTIONS = [InvenTreeOutputOption('supplier_detail')] -class PurchaseOrderMixin(SerializerContextMixin): - """Mixin class for PurchaseOrder endpoints.""" - - queryset = models.PurchaseOrder.objects.all().prefetch_related( - 'supplier', 'created_by' - ) - serializer_class = serializers.PurchaseOrderSerializer - - def get_queryset(self, *args, **kwargs): - """Return the annotated queryset for this endpoint.""" - queryset = super().get_queryset(*args, **kwargs) - - queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) - - return queryset - - -class PurchaseOrderList( - PurchaseOrderMixin, +class PurchaseOrderViewSet( + SerializerContextMixin, OrderCreateMixin, DataExportViewMixin, OutputOptionsMixin, ParameterListMixin, - ListCreateAPI, + RetrieveUpdateDestroyModelViewSet, ): - """API endpoint for accessing a list of PurchaseOrder objects. + """API endpoint for accessing PurchaseOrder objects. - - GET: Return list of PurchaseOrder objects (with filters) + - GET: Return list of PurchaseOrder objects (with filters), or a single PurchaseOrder object - POST: Create a new PurchaseOrder object + - PUT / PATCH: Update an existing PurchaseOrder object + - DELETE: Remove a PurchaseOrder object """ filterset_class = PurchaseOrderFilter filter_backends = SEARCH_ORDER_FILTER output_options = PurchaseOrderOutputOptions + queryset = models.PurchaseOrder.objects.all().prefetch_related( + 'supplier', 'created_by' + ) + serializer_class = serializers.PurchaseOrderSerializer ordering_field_aliases = { 'reference': ['reference_int', 'reference'], @@ -438,19 +436,11 @@ class PurchaseOrderList( ordering = '-reference' - -class PurchaseOrderDetail( - PurchaseOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI -): - """API endpoint for detail view of a PurchaseOrder object.""" - - output_options = PurchaseOrderOutputOptions - - -class PurchaseOrderContextMixin: - """Mixin to add purchase order object as serializer context variable.""" - - queryset = models.PurchaseOrder.objects.all() + def get_queryset(self, *args, **kwargs): + """Return the annotated queryset for this endpoint.""" + queryset = super().get_queryset(*args, **kwargs) + queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) + return queryset def get_serializer_context(self): """Add the PurchaseOrder object to the serializer context.""" @@ -468,66 +458,92 @@ class PurchaseOrderContextMixin: return context + # TODO @matmair remove legacy return codes + @extend_schema(responses={201: serializers.PurchaseOrderHoldSerializer}) + @action( + detail=True, + methods=['post'], + serializer_class=serializers.PurchaseOrderHoldSerializer, + output_options=None, + ) + def hold(self, request, pk=None): + """API endpoint to place a PurchaseOrder on hold.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) -class PurchaseOrderHold(PurchaseOrderContextMixin, CreateAPI): - """API endpoint to place a PurchaseOrder on hold.""" + # TODO @matmair remove legacy return codes + @extend_schema(responses={201: serializers.PurchaseOrderCancelSerializer}) + @action( + detail=True, + methods=['post'], + serializer_class=serializers.PurchaseOrderCancelSerializer, + output_options=None, + ) + def cancel(self, request, pk=None): + """API endpoint to 'cancel' a purchase order. - serializer_class = serializers.PurchaseOrderHoldSerializer + The purchase order must be in a state which can be cancelled + """ + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + # TODO @matmair remove legacy return codes + @extend_schema(responses={201: serializers.PurchaseOrderCompleteSerializer}) + @action( + detail=True, + methods=['post'], + serializer_class=serializers.PurchaseOrderCompleteSerializer, + output_options=None, + ) + def complete(self, request, pk=None): + """API endpoint to 'complete' a purchase order.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) -class PurchaseOrderCancel(PurchaseOrderContextMixin, CreateAPI): - """API endpoint to 'cancel' a purchase order. + # TODO @matmair remove legacy return codes + @extend_schema(responses={201: serializers.PurchaseOrderIssueSerializer}) + @action( + detail=True, + methods=['post'], + serializer_class=serializers.PurchaseOrderIssueSerializer, + output_options=None, + ) + def issue(self, request, pk=None): + """API endpoint to 'issue' (place) a PurchaseOrder.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) - The purchase order must be in a state which can be cancelled - """ - - serializer_class = serializers.PurchaseOrderCancelSerializer - - -class PurchaseOrderComplete(PurchaseOrderContextMixin, CreateAPI): - """API endpoint to 'complete' a purchase order.""" - - serializer_class = serializers.PurchaseOrderCompleteSerializer - - -class PurchaseOrderIssue(PurchaseOrderContextMixin, CreateAPI): - """API endpoint to 'issue' (place) a PurchaseOrder.""" - - 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. - - - The purchase order is specified in the URL. - - Items to receive are specified as a list called "items" with the following options: - - line_item: pk of the PO Line item - - supplier_part: pk value of the supplier part - - quantity: quantity to receive - - status: stock item status - - expiry_date: stock item expiry date (optional) - - location: destination for stock item (optional) - - batch_code: the batch code for this stock item - - serial_numbers: serial numbers for this stock item - - A global location must also be specified. This is used when no locations are specified for items, and no location is given in the PO line item - """ - - queryset = models.PurchaseOrderLineItem.objects.none() - serializer_class = serializers.PurchaseOrderReceiveSerializer - pagination_class = None - - def create(self, request, *args, **kwargs): - """Override the create method to handle stock item creation.""" + @extend_schema(responses={201: stock_serializers.StockItemSerializer(many=True)}) + @action( + detail=True, + methods=['post'], + serializer_class=serializers.PurchaseOrderReceiveSerializer, + pagination_class=None, + filter_backends=[], + output_options=None, + ) + def receive(self, request, pk=None): + """API endpoint to receive stock items against a PurchaseOrder.""" 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) + response = stock_serializers.StockItemSerializer(queryset, many=True) return Response(response.data, status=status.HTTP_201_CREATED) +order_router.register('po', PurchaseOrderViewSet, basename='api-po') + + class PurchaseOrderLineItemFilter(LineItemFilter): """Custom filters for the PurchaseOrderLineItemList endpoint.""" @@ -636,43 +652,24 @@ class PurchaseOrderLineItemOutputOptions(OutputConfiguration): ] -class PurchaseOrderLineItemMixin(SerializerContextMixin): - """Mixin class for PurchaseOrderLineItem endpoints.""" +class PurchaseOrderLineItemViewSet( + SerializerContextMixin, + DataExportViewMixin, + OutputOptionsMixin, + BulkDeleteViewsetMixin, + RetrieveUpdateDestroyModelViewSet, +): + """API endpoint for accessing PurchaseOrderLineItem objects. + + - GET: Return list of PurchaseOrderLineItem objects (with filters), or a single object + - POST: Create a new PurchaseOrderLineItem object + - PUT / PATCH: Update an existing PurchaseOrderLineItem object + - DELETE: Remove a PurchaseOrderLineItem object (or bulk delete multiple objects) + """ queryset = models.PurchaseOrderLineItem.objects.all() serializer_class = serializers.PurchaseOrderLineItemSerializer - def get_queryset(self, *args, **kwargs): - """Return annotated queryset for this endpoint.""" - queryset = super().get_queryset(*args, **kwargs) - - queryset = serializers.PurchaseOrderLineItemSerializer.annotate_queryset( - queryset - ) - - return queryset - - def perform_update(self, serializer): - """Override the perform_update method to auto-update pricing if required.""" - super().perform_update(serializer) - - # possibly auto-update pricing based on the supplier part pricing data - if serializer.validated_data.get('auto_pricing', True): - serializer.instance.update_pricing() - - -class PurchaseOrderLineItemList( - PurchaseOrderLineItemMixin, - DataExportViewMixin, - OutputOptionsMixin, - ListCreateDestroyAPIView, -): - """API endpoint for accessing a list of PurchaseOrderLineItem objects. - - - GET: Return a list of PurchaseOrder Line Item objects - - POST: Create a new PurchaseOrderLineItem object - """ - filterset_class = PurchaseOrderLineItemFilter output_options = PurchaseOrderLineItemOutputOptions @@ -766,29 +763,49 @@ class PurchaseOrderLineItemList( 'reference', ] + def get_queryset(self): + """Return annotated queryset for this endpoint.""" + queryset = super().get_queryset() + queryset = serializers.PurchaseOrderLineItemSerializer.annotate_queryset( + queryset + ) + return queryset -class PurchaseOrderLineItemDetail( - PurchaseOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI + def perform_update(self, serializer): + """Override the perform_update method to auto-update pricing if required.""" + super().perform_update(serializer) + + # possibly auto-update pricing based on the supplier part pricing data + if serializer.validated_data.get('auto_pricing', True): + serializer.instance.update_pricing() + + +order_router.register('po-line', PurchaseOrderLineItemViewSet, basename='api-po-line') + + +class PurchaseOrderExtraLineViewSet( + GeneralExtraLineList, OutputOptionsMixin, BulkDeleteViewsetMixin, CleanModelViewSet ): - """Detail API endpoint for PurchaseOrderLineItem object.""" + """API endpoint for accessing PurchaseOrderExtraLine objects. - output_options = PurchaseOrderLineItemOutputOptions - - -class PurchaseOrderExtraLineList( - GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView -): - """API endpoint for accessing a list of PurchaseOrderExtraLine objects.""" + - GET: Return list of PurchaseOrderExtraLine objects (with filters), or a single object + - POST: Create a new PurchaseOrderExtraLine object + - PUT / PATCH: Update an existing PurchaseOrderExtraLine object + - DELETE: Remove a PurchaseOrderExtraLine object (or bulk delete multiple objects) + """ queryset = models.PurchaseOrderExtraLine.objects.all() serializer_class = serializers.PurchaseOrderExtraLineSerializer + def get_queryset(self): + """Return the annotated queryset for this endpoint.""" + queryset = super().get_queryset() + return queryset.prefetch_related('order') -class PurchaseOrderExtraLineDetail(RetrieveUpdateDestroyAPI): - """API endpoint for detail view of a PurchaseOrderExtraLine object.""" - queryset = models.PurchaseOrderExtraLine.objects.all() - serializer_class = serializers.PurchaseOrderExtraLineSerializer +order_router.register( + 'po-extra-line', PurchaseOrderExtraLineViewSet, basename='api-po-extra-line' +) class SalesOrderFilter(OrderFilter): @@ -2551,82 +2568,14 @@ class OrderCalendarExport(ICalFeed): order_api_urls = [ - # API endpoints for purchase orders + # Purchase Order, Line Item, and Extra Line API endpoints via ViewSet router + path('', include(order_router.urls)), + # Purchase order status code information (requires custom kwargs) path( - 'po/', - include([ - # Individual purchase order detail URLs - path( - '/', - include([ - path( - 'cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel' - ), - path('hold/', PurchaseOrderHold.as_view(), name='api-po-hold'), - path( - 'complete/', - PurchaseOrderComplete.as_view(), - name='api-po-complete', - ), - path('issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'), - meta_path(models.PurchaseOrder), - path( - 'receive/', - PurchaseOrderReceive.as_view(), - name='api-po-receive', - ), - # PurchaseOrder detail API endpoint - path('', PurchaseOrderDetail.as_view(), name='api-po-detail'), - ]), - ), - # Purchase order status code information - path( - 'status/', - StatusView.as_view(), - {StatusView.MODEL_REF: PurchaseOrderStatus}, - name='api-po-status-codes', - ), - # Purchase order list - path('', PurchaseOrderList.as_view(), name='api-po-list'), - ]), - ), - # API endpoints for purchase order line items - path( - 'po-line/', - include([ - path( - '/', - include([ - meta_path(models.PurchaseOrderLineItem), - path( - '', - PurchaseOrderLineItemDetail.as_view(), - name='api-po-line-detail', - ), - ]), - ), - path('', PurchaseOrderLineItemList.as_view(), name='api-po-line-list'), - ]), - ), - # API endpoints for purchase order extra line - path( - 'po-extra-line/', - include([ - path( - '/', - include([ - meta_path(models.PurchaseOrderExtraLine), - path( - '', - PurchaseOrderExtraLineDetail.as_view(), - name='api-po-extra-line-detail', - ), - ]), - ), - path( - '', PurchaseOrderExtraLineList.as_view(), name='api-po-extra-line-list' - ), - ]), + 'po/status/', + StatusView.as_view(), + {StatusView.MODEL_REF: PurchaseOrderStatus}, + name='api-po-status-codes', ), # API endpoints for sales orders path( diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 893976cdb2..6752a3b575 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -719,6 +719,18 @@ class PurchaseOrderTest(OrderTest): po.refresh_from_db() self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE) + def test_po_hold(self): + """Test the PurchaseOrderHold API endpoint.""" + po = models.PurchaseOrder.objects.get(pk=1) + url = reverse('api-po-hold', kwargs={'pk': po.pk}) + + # Try to hold the PO, without required permissions + self.post(url, {}, expected_code=403) + self.assignRole('purchase_order.add') + self.post(url, {}, expected_code=201) + po.refresh_from_db() + self.assertEqual(po.status, PurchaseOrderStatus.ON_HOLD) + def test_po_issue(self): """Test the PurchaseOrderIssue API endpoint.""" po = models.PurchaseOrder.objects.get(pk=2)