refactor PurchaseOrder APIs to viewset (#12317)

* refactor PurchaseOrder APIs to viewser

* reduce diff/api change

* replicate legacy api return codes

* fix serializer

* fix return code

* fix viewsets

* clean up docstrings

* ensure stuff is cleaned correctly

* distribute CleanMixin better down MRO

* move for mro reasons

* add prefetching

* update APISearchView to support viewsets

* fix apiserch for modelviewsets

* fix names

* Apply suggestions from code review

Co-authored-by: Matthias Mair <code@mjmair.com>

* add test for hold
This commit is contained in:
Matthias Mair
2026-08-20 19:54:46 +10:00
committed by GitHub
parent 501efdcb6e
commit a48ba58ee9
8 changed files with 236 additions and 226 deletions
+17 -4
View File
@@ -8,7 +8,7 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction from django.db import transaction
from django.http import JsonResponse from django.http import HttpRequest, JsonResponse
from django.urls import path, reverse from django.urls import path, reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
@@ -780,7 +780,7 @@ class APISearchView(GenericAPIView):
'supplierpart': company.api.SupplierPartList, 'supplierpart': company.api.SupplierPartList,
'part': part.api.PartList, 'part': part.api.PartList,
'partcategory': part.api.CategoryList, 'partcategory': part.api.CategoryList,
'purchaseorder': order.api.PurchaseOrderList, 'purchaseorder': order.api.PurchaseOrderViewSet,
'returnorder': order.api.ReturnOrderList, 'returnorder': order.api.ReturnOrderList,
'salesorder': order.api.SalesOrderList, 'salesorder': order.api.SalesOrderList,
'salesordershipment': order.api.SalesOrderShipmentList, 'salesordershipment': order.api.SalesOrderShipmentList,
@@ -844,7 +844,10 @@ class APISearchView(GenericAPIView):
if type(params) is not dict: if type(params) is not dict:
continue 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 # Override regular query params with specific ones for this search request
cloned_request._request.GET = params cloned_request._request.GET = params
@@ -863,7 +866,17 @@ class APISearchView(GenericAPIView):
continue continue
try: 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: except Exception as exc:
results[key] = {'error': str(exc)} results[key] = {'error': str(exc)}
@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 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) - Adds "piece_count" field to the BomItem model and API endpoints (for cut-to-length parts)
@@ -1,11 +1,30 @@
"""Helpers for InvenTrees way of using drf viewset.""" """Helpers for InvenTrees way of using drf viewset."""
from rest_framework import mixins, routers, viewsets from rest_framework import mixins, routers, viewsets
from rest_framework.settings import api_settings
from InvenTree.api import BulkDeleteViewsetMixin 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( class RetrieveUpdateDestroyModelViewSet(
CleanUpdateOnlyMixin,
ViewSetCleanMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.UpdateModelMixin, mixins.UpdateModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
+22 -10
View File
@@ -17,14 +17,9 @@ from InvenTree.schema import schema_for_view_output_options
from InvenTree.serializers import FilterableSerializerMixin from InvenTree.serializers import FilterableSerializerMixin
class CleanMixin: class CleanCreate: # noqa: D101
"""Model mixin class which cleans inputs using nh3.""" def create(self, request, *args, **kwargs): # noqa: D102
# Override to clean data before processing it
# 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."""
serializer = self.get_serializer(data=self.clean_data(request.data)) serializer = self.get_serializer(data=self.clean_data(request.data))
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
self.perform_create(serializer) self.perform_create(serializer)
@@ -33,8 +28,10 @@ class CleanMixin:
serializer.data, status=status.HTTP_201_CREATED, headers=headers 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) partial = kwargs.pop('partial', False)
instance = self.get_object() instance = self.get_object()
serializer = self.get_serializer( serializer = self.get_serializer(
@@ -50,6 +47,13 @@ class CleanMixin:
return Response(serializer.data) 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: def clean_string(self, field: str, data: str) -> str:
"""Clean / sanitize a single input string.""" """Clean / sanitize a single input string."""
cleaned = data cleaned = data
@@ -119,6 +123,14 @@ class CleanMixin:
return clean_data 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): class ListAPI(generics.ListAPIView):
"""View for list API.""" """View for list API."""
+6 -4
View File
@@ -16,7 +16,7 @@ from drf_spectacular.utils import (
extend_schema, extend_schema,
extend_schema_view, extend_schema_view,
) )
from rest_framework import serializers from rest_framework import serializers, viewsets
from rest_framework.pagination import LimitOffsetPagination from rest_framework.pagination import LimitOffsetPagination
from InvenTree.permissions import OASTokenMixin from InvenTree.permissions import OASTokenMixin
@@ -358,9 +358,11 @@ def schema_for_view_output_options(view_class):
) )
parameters.append(param) parameters.append(param)
extended_view = extend_schema_view(get=extend_schema(parameters=parameters))( # DRF viewsets dispatch GET requests to the 'list' action, rather than a 'get' method
view_class 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 return extended_view
@@ -432,8 +432,8 @@ class DataExportViewMixin:
# Update the output object with the exported data # Update the output object with the exported data
output.mark_complete(output=ContentFile(datafile, filename)) output.mark_complete(output=ContentFile(datafile, filename))
def get(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
"""Override the GET method to determine export options.""" """Override the list method to determine export options."""
from common.serializers import DataOutputSerializer from common.serializers import DataOutputSerializer
# If we are not exporting data, return the default response # If we are not exporting data, return the default response
@@ -504,4 +504,4 @@ class DataExportViewMixin:
# Return a response to the frontend # Return a response to the frontend
return Response(DataOutputSerializer(output).data, status=200) return Response(DataOutputSerializer(output).data, status=200)
return super().get(request, *args, **kwargs) return super().list(request, *args, **kwargs)
+143 -194
View File
@@ -19,6 +19,7 @@ from django_ical.views import ICalFeed
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_field from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.response import Response from rest_framework.response import Response
@@ -34,6 +35,7 @@ from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView from generic.states.api import StatusView
from InvenTree.api import ( from InvenTree.api import (
BulkDeleteMixin, BulkDeleteMixin,
BulkDeleteViewsetMixin,
BulkUpdateMixin, BulkUpdateMixin,
ListCreateDestroyAPIView, ListCreateDestroyAPIView,
ParameterListMixin, ParameterListMixin,
@@ -42,6 +44,11 @@ from InvenTree.api import (
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import SEARCH_ORDER_FILTER, InvenTreeDateFilter from InvenTree.filters import SEARCH_ORDER_FILTER, InvenTreeDateFilter
from InvenTree.helpers import current_date, str2bool 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.helpers_model import construct_absolute_url, get_base_url
from InvenTree.mixins import ( from InvenTree.mixins import (
CreateAPI, CreateAPI,
@@ -65,6 +72,8 @@ from order.status_codes import (
from part.models import Part from part.models import Part
from users.models import Owner from users.models import Owner
order_router = InvenTreeApiRouter()
class GeneralExtraLineListOutputOptions(OutputConfiguration): class GeneralExtraLineListOutputOptions(OutputConfiguration):
"""Output options for the GeneralExtraLineList endpoint.""" """Output options for the GeneralExtraLineList endpoint."""
@@ -372,40 +381,29 @@ class PurchaseOrderOutputOptions(OutputConfiguration):
OPTIONS = [InvenTreeOutputOption('supplier_detail')] OPTIONS = [InvenTreeOutputOption('supplier_detail')]
class PurchaseOrderMixin(SerializerContextMixin): class PurchaseOrderViewSet(
"""Mixin class for PurchaseOrder endpoints.""" SerializerContextMixin,
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,
OrderCreateMixin, OrderCreateMixin,
DataExportViewMixin, DataExportViewMixin,
OutputOptionsMixin, OutputOptionsMixin,
ParameterListMixin, 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 - POST: Create a new PurchaseOrder object
- PUT / PATCH: Update an existing PurchaseOrder object
- DELETE: Remove a PurchaseOrder object
""" """
filterset_class = PurchaseOrderFilter filterset_class = PurchaseOrderFilter
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
output_options = PurchaseOrderOutputOptions output_options = PurchaseOrderOutputOptions
queryset = models.PurchaseOrder.objects.all().prefetch_related(
'supplier', 'created_by'
)
serializer_class = serializers.PurchaseOrderSerializer
ordering_field_aliases = { ordering_field_aliases = {
'reference': ['reference_int', 'reference'], 'reference': ['reference_int', 'reference'],
@@ -438,19 +436,11 @@ class PurchaseOrderList(
ordering = '-reference' ordering = '-reference'
def get_queryset(self, *args, **kwargs):
class PurchaseOrderDetail( """Return the annotated queryset for this endpoint."""
PurchaseOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI queryset = super().get_queryset(*args, **kwargs)
): queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset)
"""API endpoint for detail view of a PurchaseOrder object.""" return queryset
output_options = PurchaseOrderOutputOptions
class PurchaseOrderContextMixin:
"""Mixin to add purchase order object as serializer context variable."""
queryset = models.PurchaseOrder.objects.all()
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."""
@@ -468,66 +458,92 @@ class PurchaseOrderContextMixin:
return context return context
# TODO @matmair remove legacy return codes
class PurchaseOrderHold(PurchaseOrderContextMixin, CreateAPI): @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.""" """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)
serializer_class = serializers.PurchaseOrderHoldSerializer # TODO @matmair remove legacy return codes
@extend_schema(responses={201: serializers.PurchaseOrderCancelSerializer})
@action(
class PurchaseOrderCancel(PurchaseOrderContextMixin, CreateAPI): detail=True,
methods=['post'],
serializer_class=serializers.PurchaseOrderCancelSerializer,
output_options=None,
)
def cancel(self, request, pk=None):
"""API endpoint to 'cancel' a purchase order. """API endpoint to 'cancel' a purchase order.
The purchase order must be in a state which can be cancelled 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)
serializer_class = serializers.PurchaseOrderCancelSerializer # TODO @matmair remove legacy return codes
@extend_schema(responses={201: serializers.PurchaseOrderCompleteSerializer})
@action(
class PurchaseOrderComplete(PurchaseOrderContextMixin, CreateAPI): detail=True,
methods=['post'],
serializer_class=serializers.PurchaseOrderCompleteSerializer,
output_options=None,
)
def complete(self, request, pk=None):
"""API endpoint to 'complete' a purchase order.""" """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)
serializer_class = serializers.PurchaseOrderCompleteSerializer # TODO @matmair remove legacy return codes
@extend_schema(responses={201: serializers.PurchaseOrderIssueSerializer})
@action(
class PurchaseOrderIssue(PurchaseOrderContextMixin, CreateAPI): detail=True,
methods=['post'],
serializer_class=serializers.PurchaseOrderIssueSerializer,
output_options=None,
)
def issue(self, request, pk=None):
"""API endpoint to 'issue' (place) a PurchaseOrder.""" """API endpoint to 'issue' (place) a PurchaseOrder."""
serializer = self.get_serializer(data=request.data)
serializer_class = serializers.PurchaseOrderIssueSerializer serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@extend_schema(responses={201: stock_serializers.StockItemSerializer(many=True)}) @extend_schema(responses={201: stock_serializers.StockItemSerializer(many=True)})
class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI): @action(
"""API endpoint to receive stock items against a PurchaseOrder. detail=True,
methods=['post'],
- The purchase order is specified in the URL. serializer_class=serializers.PurchaseOrderReceiveSerializer,
- Items to receive are specified as a list called "items" with the following options: pagination_class=None,
- line_item: pk of the PO Line item filter_backends=[],
- supplier_part: pk value of the supplier part output_options=None,
- quantity: quantity to receive )
- status: stock item status def receive(self, request, pk=None):
- expiry_date: stock item expiry date (optional) """API endpoint to receive stock items against a PurchaseOrder."""
- 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."""
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()
queryset = stock_serializers.StockItemSerializer.annotate_queryset(items) 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) return Response(response.data, status=status.HTTP_201_CREATED)
order_router.register('po', PurchaseOrderViewSet, basename='api-po')
class PurchaseOrderLineItemFilter(LineItemFilter): class PurchaseOrderLineItemFilter(LineItemFilter):
"""Custom filters for the PurchaseOrderLineItemList endpoint.""" """Custom filters for the PurchaseOrderLineItemList endpoint."""
@@ -636,43 +652,24 @@ class PurchaseOrderLineItemOutputOptions(OutputConfiguration):
] ]
class PurchaseOrderLineItemMixin(SerializerContextMixin): class PurchaseOrderLineItemViewSet(
"""Mixin class for PurchaseOrderLineItem endpoints.""" 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() queryset = models.PurchaseOrderLineItem.objects.all()
serializer_class = serializers.PurchaseOrderLineItemSerializer 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 filterset_class = PurchaseOrderLineItemFilter
output_options = PurchaseOrderLineItemOutputOptions output_options = PurchaseOrderLineItemOutputOptions
@@ -766,29 +763,49 @@ class PurchaseOrderLineItemList(
'reference', '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( def perform_update(self, serializer):
PurchaseOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI """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 - 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
class PurchaseOrderExtraLineList( - DELETE: Remove a PurchaseOrderExtraLine object (or bulk delete multiple objects)
GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView """
):
"""API endpoint for accessing a list of PurchaseOrderExtraLine objects."""
queryset = models.PurchaseOrderExtraLine.objects.all() queryset = models.PurchaseOrderExtraLine.objects.all()
serializer_class = serializers.PurchaseOrderExtraLineSerializer 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() order_router.register(
serializer_class = serializers.PurchaseOrderExtraLineSerializer 'po-extra-line', PurchaseOrderExtraLineViewSet, basename='api-po-extra-line'
)
class SalesOrderFilter(OrderFilter): class SalesOrderFilter(OrderFilter):
@@ -2551,83 +2568,15 @@ class OrderCalendarExport(ICalFeed):
order_api_urls = [ 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( path(
'po/', 'po/status/',
include([
# Individual purchase order detail URLs
path(
'<int:pk>/',
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.as_view(),
{StatusView.MODEL_REF: PurchaseOrderStatus}, {StatusView.MODEL_REF: PurchaseOrderStatus},
name='api-po-status-codes', 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(
'<int:pk>/',
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(
'<int:pk>/',
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'
),
]),
),
# API endpoints for sales orders # API endpoints for sales orders
path( path(
'so/', 'so/',
+12
View File
@@ -719,6 +719,18 @@ class PurchaseOrderTest(OrderTest):
po.refresh_from_db() po.refresh_from_db()
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE) 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): def test_po_issue(self):
"""Test the PurchaseOrderIssue API endpoint.""" """Test the PurchaseOrderIssue API endpoint."""
po = models.PurchaseOrder.objects.get(pk=2) po = models.PurchaseOrder.objects.get(pk=2)