mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-20 20:19:44 +00:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+144
-195
@@ -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.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
serializer_class = serializers.PurchaseOrderIssueSerializer
|
@extend_schema(responses={201: stock_serializers.StockItemSerializer(many=True)})
|
||||||
|
@action(
|
||||||
|
detail=True,
|
||||||
@extend_schema(responses={201: stock_serializers.StockItemSerializer(many=True)})
|
methods=['post'],
|
||||||
class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI):
|
serializer_class=serializers.PurchaseOrderReceiveSerializer,
|
||||||
"""API endpoint to receive stock items against a PurchaseOrder.
|
pagination_class=None,
|
||||||
|
filter_backends=[],
|
||||||
- The purchase order is specified in the URL.
|
output_options=None,
|
||||||
- Items to receive are specified as a list called "items" with the following options:
|
)
|
||||||
- line_item: pk of the PO Line item
|
def receive(self, request, pk=None):
|
||||||
- supplier_part: pk value of the supplier part
|
"""API endpoint to receive stock items against a PurchaseOrder."""
|
||||||
- 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."""
|
|
||||||
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/',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user