Coverage for src/backend/InvenTree/order/api.py: 72%
870 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""JSON API for the Order app."""
3from decimal import Decimal
4from typing import cast
6from django.conf import settings
7from django.contrib.auth import authenticate, login
8from django.contrib.auth.models import User
9from django.db import transaction
10from django.db.models import F, Q
11from django.http.response import JsonResponse
12from django.urls import include, path, re_path
13from django.utils.translation import gettext_lazy as _
15import django_filters.rest_framework.filters as rest_filters
16import rest_framework.serializers
17from django_filters.rest_framework.filterset import FilterSet
18from django_ical.views import ICalFeed
19from drf_spectacular.types import OpenApiTypes
20from drf_spectacular.utils import extend_schema, extend_schema_field
21from rest_framework import status
22from rest_framework.exceptions import NotFound
23from rest_framework.response import Response
25import build.models
26import common.filters
27import common.models
28import common.serializers
29import common.settings
30import company.models
31import stock.models as stock_models
32import stock.serializers as stock_serializers
33from data_exporter.mixins import DataExportViewMixin
34from generic.states.api import StatusView
35from InvenTree.api import (
36 BulkDeleteMixin,
37 BulkUpdateMixin,
38 ListCreateDestroyAPIView,
39 ParameterListMixin,
40 meta_path,
41)
42from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
43from InvenTree.filters import SEARCH_ORDER_FILTER, InvenTreeDateFilter
44from InvenTree.helpers import current_date, str2bool
45from InvenTree.helpers_model import construct_absolute_url, get_base_url
46from InvenTree.mixins import (
47 CreateAPI,
48 ListAPI,
49 ListCreateAPI,
50 OutputOptionsMixin,
51 RetrieveUpdateDestroyAPI,
52 SerializerContextMixin,
53)
54from order import models, serializers
55from order.status_codes import (
56 PurchaseOrderStatus,
57 PurchaseOrderStatusGroups,
58 ReturnOrderLineStatus,
59 ReturnOrderStatus,
60 SalesOrderStatus,
61 SalesOrderStatusGroups,
62 TransferOrderStatus,
63 TransferOrderStatusGroups,
64)
65from part.models import Part
66from users.models import Owner
69class GeneralExtraLineListOutputOptions(OutputConfiguration):
70 """Output options for the GeneralExtraLineList endpoint."""
72 OPTIONS = [InvenTreeOutputOption('order_detail')]
75class GeneralExtraLineList(SerializerContextMixin, DataExportViewMixin):
76 """General template for ExtraLine API classes."""
78 def get_queryset(self, *args, **kwargs):
79 """Return the annotated queryset for this endpoint."""
80 queryset = super().get_queryset(*args, **kwargs)
82 queryset = queryset.prefetch_related('order')
84 return queryset
86 output_options = GeneralExtraLineListOutputOptions
88 filter_backends = SEARCH_ORDER_FILTER
90 ordering_fields = ['quantity', 'notes', 'reference', 'line']
92 ordering_field_aliases = {'line': ['line_int', 'line']}
94 search_fields = ['quantity', 'notes', 'reference', 'description']
96 filterset_fields = ['order']
99class OrderCreateMixin:
100 """Mixin class which handles order creation via API."""
102 def create(self, request, *args, **kwargs):
103 """Save user information on order creation."""
104 serializer = self.get_serializer(data=self.clean_data(request.data))
105 serializer.is_valid(raise_exception=True)
107 serializer.save(created_by=request.user)
109 headers = self.get_success_headers(serializer.data)
110 return Response(
111 serializer.data, status=status.HTTP_201_CREATED, headers=headers
112 )
115class OrderFilter(FilterSet):
116 """Base class for custom API filters for the OrderList endpoint."""
118 # Filter against order status
119 status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
121 def filter_status(self, queryset, name, value):
122 """Filter by integer status code.
124 Note: Also account for the possibility of a custom status code.
125 """
126 q1 = Q(status=value, status_custom_key__isnull=True)
127 q2 = Q(status_custom_key=value)
129 return queryset.filter(q1 | q2).distinct()
131 # Exact match for reference
132 reference = rest_filters.CharFilter(
133 label=_('Order Reference'), field_name='reference', lookup_expr='iexact'
134 )
136 assigned_to_me = rest_filters.BooleanFilter(
137 label=_('Assigned to me'), method='filter_assigned_to_me'
138 )
140 def filter_assigned_to_me(self, queryset, name, value):
141 """Filter by orders which are assigned to the current user."""
142 # Work out who "me" is!
143 owners = Owner.get_owners_matching_user(self.request.user)
145 if str2bool(value): 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true
146 return queryset.filter(responsible__in=owners)
147 return queryset.exclude(responsible__in=owners)
149 overdue = rest_filters.BooleanFilter(label='overdue', method='filter_overdue')
151 def filter_overdue(self, queryset, name, value):
152 """Generic filter for determining if an order is 'overdue'.
154 Note that the overdue_filter() classmethod must be defined for the model
155 """
156 if str2bool(value):
157 return queryset.filter(self.Meta.model.overdue_filter())
158 return queryset.exclude(self.Meta.model.overdue_filter())
160 outstanding = rest_filters.BooleanFilter(
161 label=_('Outstanding'), method='filter_outstanding'
162 )
164 def filter_outstanding(self, queryset, name, value):
165 """Generic filter for determining if an order is 'outstanding'."""
166 if str2bool(value): 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 return queryset.filter(status__in=self.Meta.model.get_status_class().OPEN)
168 return queryset.exclude(status__in=self.Meta.model.get_status_class().OPEN)
170 project_code = rest_filters.ModelChoiceFilter(
171 queryset=common.models.ProjectCode.objects.all(),
172 field_name='project_code',
173 label=_('Project Code'),
174 )
176 has_project_code = rest_filters.BooleanFilter(
177 method='filter_has_project_code', label=_('Has Project Code')
178 )
180 def filter_has_project_code(self, queryset, name, value):
181 """Filter by whether or not the order has a project code."""
182 if str2bool(value):
183 return queryset.exclude(project_code=None)
184 return queryset.filter(project_code=None)
186 assigned_to = rest_filters.ModelChoiceFilter(
187 queryset=Owner.objects.all(), field_name='responsible', label=_('Responsible')
188 )
190 created_by = rest_filters.ModelChoiceFilter(
191 queryset=User.objects.all(), field_name='created_by', label=_('Created By')
192 )
194 created_before = InvenTreeDateFilter(
195 label=_('Created Before'), field_name='creation_date', lookup_expr='lt'
196 )
198 created_after = InvenTreeDateFilter(
199 label=_('Created After'), field_name='creation_date', lookup_expr='gt'
200 )
202 has_start_date = rest_filters.BooleanFilter(
203 label=_('Has Start Date'), method='filter_has_start_date'
204 )
206 def filter_has_start_date(self, queryset, name, value):
207 """Filter by whether or not the order has a start date."""
208 return queryset.filter(start_date__isnull=not str2bool(value))
210 start_date_before = InvenTreeDateFilter(
211 label=_('Start Date Before'), field_name='start_date', lookup_expr='lt'
212 )
214 start_date_after = InvenTreeDateFilter(
215 label=_('Start Date After'), field_name='start_date', lookup_expr='gt'
216 )
218 has_target_date = rest_filters.BooleanFilter(
219 label=_('Has Target Date'), method='filter_has_target_date'
220 )
222 def filter_has_target_date(self, queryset, name, value):
223 """Filter by whether or not the order has a target date."""
224 return queryset.filter(target_date__isnull=not str2bool(value))
226 target_date_before = InvenTreeDateFilter(
227 label=_('Target Date Before'), field_name='target_date', lookup_expr='lt'
228 )
230 target_date_after = InvenTreeDateFilter(
231 label=_('Target Date After'), field_name='target_date', lookup_expr='gt'
232 )
234 updated_before = InvenTreeDateFilter(
235 label=_('Updated Before'), field_name='updated_at', lookup_expr='lt'
236 )
238 updated_after = InvenTreeDateFilter(
239 label=_('Updated After'), field_name='updated_at', lookup_expr='gt'
240 )
242 min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date')
244 def filter_min_date(self, queryset, name, value):
245 """Filter the queryset to include orders *after* a specified date.
247 This is used in combination with filter_max_date,
248 to provide a queryset which matches a particular range of dates.
250 In particular, this is used in the UI for the calendar view.
251 """
252 q1 = Q(
253 creation_date__gte=value, issue_date__isnull=True, start_date__isnull=True
254 )
255 q2 = Q(issue_date__gte=value, start_date__isnull=True)
256 q3 = Q(start_date__gte=value)
257 q4 = Q(target_date__gte=value)
259 return queryset.filter(q1 | q2 | q3 | q4).distinct()
261 max_date = InvenTreeDateFilter(label=_('Max Date'), method='filter_max_date')
263 def filter_max_date(self, queryset, name, value):
264 """Filter the queryset to include orders *before* a specified date.
266 This is used in combination with filter_min_date,
267 to provide a queryset which matches a particular range of dates.
269 In particular, this is used in the UI for the calendar view.
270 """
271 q1 = Q(
272 creation_date__lte=value, issue_date__isnull=True, start_date__isnull=True
273 )
274 q2 = Q(issue_date__lte=value, start_date__isnull=True)
275 q3 = Q(start_date__lte=value)
276 q4 = Q(target_date__lte=value)
278 return queryset.filter(q1 | q2 | q3 | q4).distinct()
280 tags = common.filters.TagsFilter()
283class LineItemFilter(FilterSet):
284 """Base class for custom API filters for order line item list(s)."""
286 # Filter by order status
287 order_status = rest_filters.NumberFilter(
288 label=_('Order Status'), field_name='order__status'
289 )
291 has_pricing = rest_filters.BooleanFilter(
292 label=_('Has Pricing'), method='filter_has_pricing'
293 )
295 def filter_has_pricing(self, queryset, name, value):
296 """Filter by whether or not the line item has pricing information."""
297 filters = {self.Meta.price_field: None}
299 if str2bool(value):
300 return queryset.exclude(**filters)
301 return queryset.filter(**filters)
304class PurchaseOrderFilter(OrderFilter):
305 """Custom API filters for the PurchaseOrderList endpoint."""
307 class Meta:
308 """Metaclass options."""
310 model = models.PurchaseOrder
311 fields = ['supplier']
313 part = rest_filters.ModelChoiceFilter(
314 queryset=Part.objects.all(),
315 field_name='part',
316 label=_('Part'),
317 method='filter_part',
318 )
320 @extend_schema_field(rest_framework.serializers.IntegerField(help_text=_('Part')))
321 def filter_part(self, queryset, name, part: Part):
322 """Filter by provided Part instance."""
323 orders = part.purchase_orders()
325 return queryset.filter(pk__in=[o.pk for o in orders])
327 supplier_part = rest_filters.ModelChoiceFilter(
328 queryset=company.models.SupplierPart.objects.all(),
329 label=_('Supplier Part'),
330 method='filter_supplier_part',
331 )
333 @extend_schema_field(
334 rest_framework.serializers.IntegerField(help_text=_('Supplier Part'))
335 )
336 def filter_supplier_part(
337 self, queryset, name, supplier_part: company.models.SupplierPart
338 ):
339 """Filter by provided SupplierPart instance."""
340 orders = supplier_part.purchase_orders()
342 return queryset.filter(pk__in=[o.pk for o in orders])
344 completed_before = InvenTreeDateFilter(
345 label=_('Completed Before'), field_name='complete_date', lookup_expr='lt'
346 )
348 completed_after = InvenTreeDateFilter(
349 label=_('Completed After'), field_name='complete_date', lookup_expr='gt'
350 )
352 external_build = rest_filters.ModelChoiceFilter(
353 queryset=build.models.Build.objects.filter(external=True),
354 method='filter_external_build',
355 label=_('External Build Order'),
356 )
358 @extend_schema_field(
359 rest_framework.serializers.IntegerField(help_text=_('External Build Order'))
360 )
361 def filter_external_build(self, queryset, name, build):
362 """Filter to only include orders which fill fulfil the provided Build Order.
364 To achieve this, we return any order which has a line item which is allocated to the build order.
365 """
366 return queryset.filter(lines__build_order=build).distinct()
369class PurchaseOrderOutputOptions(OutputConfiguration):
370 """Output options for the PurchaseOrder endpoint."""
372 OPTIONS = [InvenTreeOutputOption('supplier_detail')]
375class PurchaseOrderMixin(SerializerContextMixin):
376 """Mixin class for PurchaseOrder endpoints."""
378 queryset = models.PurchaseOrder.objects.all().prefetch_related(
379 'supplier', 'created_by'
380 )
381 serializer_class = serializers.PurchaseOrderSerializer
383 def get_queryset(self, *args, **kwargs):
384 """Return the annotated queryset for this endpoint."""
385 queryset = super().get_queryset(*args, **kwargs)
387 queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset)
389 return queryset
392class PurchaseOrderList(
393 PurchaseOrderMixin,
394 OrderCreateMixin,
395 DataExportViewMixin,
396 OutputOptionsMixin,
397 ParameterListMixin,
398 ListCreateAPI,
399):
400 """API endpoint for accessing a list of PurchaseOrder objects.
402 - GET: Return list of PurchaseOrder objects (with filters)
403 - POST: Create a new PurchaseOrder object
404 """
406 filterset_class = PurchaseOrderFilter
407 filter_backends = SEARCH_ORDER_FILTER
408 output_options = PurchaseOrderOutputOptions
410 ordering_field_aliases = {
411 'reference': ['reference_int', 'reference'],
412 'project_code': ['project_code__code'],
413 }
415 search_fields = [
416 'reference',
417 'supplier__name',
418 'supplier_reference',
419 'project_code__code',
420 'description',
421 ]
423 ordering_fields = [
424 'creation_date',
425 'created_by',
426 'reference',
427 'supplier__name',
428 'start_date',
429 'target_date',
430 'complete_date',
431 'line_items',
432 'status',
433 'responsible',
434 'total_price',
435 'project_code',
436 'updated_at',
437 ]
439 ordering = '-reference'
442class PurchaseOrderDetail(
443 PurchaseOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
444):
445 """API endpoint for detail view of a PurchaseOrder object."""
447 output_options = PurchaseOrderOutputOptions
450class PurchaseOrderContextMixin:
451 """Mixin to add purchase order object as serializer context variable."""
453 queryset = models.PurchaseOrder.objects.all()
455 def get_serializer_context(self):
456 """Add the PurchaseOrder object to the serializer context."""
457 context = super().get_serializer_context()
459 # Pass the purchase order through to the serializer for validation
460 try:
461 context['order'] = models.PurchaseOrder.objects.get(
462 pk=self.kwargs.get('pk', None)
463 )
464 except Exception:
465 pass
467 context['request'] = self.request
469 return context
472class PurchaseOrderHold(PurchaseOrderContextMixin, CreateAPI):
473 """API endpoint to place a PurchaseOrder on hold."""
475 serializer_class = serializers.PurchaseOrderHoldSerializer
478class PurchaseOrderCancel(PurchaseOrderContextMixin, CreateAPI):
479 """API endpoint to 'cancel' a purchase order.
481 The purchase order must be in a state which can be cancelled
482 """
484 serializer_class = serializers.PurchaseOrderCancelSerializer
487class PurchaseOrderComplete(PurchaseOrderContextMixin, CreateAPI):
488 """API endpoint to 'complete' a purchase order."""
490 serializer_class = serializers.PurchaseOrderCompleteSerializer
493class PurchaseOrderIssue(PurchaseOrderContextMixin, CreateAPI):
494 """API endpoint to 'issue' (place) a PurchaseOrder."""
496 serializer_class = serializers.PurchaseOrderIssueSerializer
499@extend_schema(responses={201: stock_serializers.StockItemSerializer(many=True)})
500class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI):
501 """API endpoint to receive stock items against a PurchaseOrder.
503 - The purchase order is specified in the URL.
504 - Items to receive are specified as a list called "items" with the following options:
505 - line_item: pk of the PO Line item
506 - supplier_part: pk value of the supplier part
507 - quantity: quantity to receive
508 - status: stock item status
509 - expiry_date: stock item expiry date (optional)
510 - location: destination for stock item (optional)
511 - batch_code: the batch code for this stock item
512 - serial_numbers: serial numbers for this stock item
513 - 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
514 """
516 queryset = models.PurchaseOrderLineItem.objects.none()
517 serializer_class = serializers.PurchaseOrderReceiveSerializer
518 pagination_class = None
520 def create(self, request, *args, **kwargs):
521 """Override the create method to handle stock item creation."""
522 serializer = self.get_serializer(data=request.data)
523 serializer.is_valid(raise_exception=True)
524 items = serializer.save()
525 queryset = stock_serializers.StockItemSerializer.annotate_queryset(items)
526 response = stock_serializers.StockItemSerializer(queryset, many=True)
528 return Response(response.data, status=status.HTTP_201_CREATED)
531class PurchaseOrderLineItemFilter(LineItemFilter):
532 """Custom filters for the PurchaseOrderLineItemList endpoint."""
534 class Meta:
535 """Metaclass options."""
537 price_field = 'purchase_price'
538 model = models.PurchaseOrderLineItem
539 fields = []
541 order = rest_filters.ModelChoiceFilter(
542 queryset=models.PurchaseOrder.objects.all(),
543 field_name='order',
544 label=_('Order'),
545 )
547 order_complete = rest_filters.BooleanFilter(
548 label=_('Order Complete'), method='filter_order_complete'
549 )
551 def filter_order_complete(self, queryset, name, value):
552 """Filter by whether the order is 'complete' or not."""
553 if str2bool(value):
554 return queryset.filter(order__status=PurchaseOrderStatus.COMPLETE.value)
556 return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value)
558 part = rest_filters.ModelChoiceFilter(
559 queryset=company.models.SupplierPart.objects.all(),
560 field_name='part',
561 label=_('Supplier Part'),
562 )
564 include_variants = rest_filters.BooleanFilter(
565 label=_('Include Variants'), method='filter_include_variants'
566 )
568 def filter_include_variants(self, queryset, name, value):
569 """Filter by whether or not to include variants of the selected part.
571 Note:
572 - This filter does nothing by itself, and requires the 'base_part' filter to be set.
573 - Refer to the 'filter_base_part' method for more information.
574 """
575 return queryset
577 base_part = rest_filters.ModelChoiceFilter(
578 queryset=Part.objects.filter(purchaseable=True),
579 method='filter_base_part',
580 label=_('Internal Part'),
581 )
583 @extend_schema_field(
584 rest_framework.serializers.IntegerField(help_text=_('Internal Part'))
585 )
586 def filter_base_part(self, queryset, name, base_part):
587 """Filter by the 'base_part' attribute.
589 Note:
590 - If "include_variants" is True, include all variants of the selected part
591 - Otherwise, just filter by the selected part
592 """
593 include_variants = str2bool(self.data.get('include_variants', False))
595 if include_variants:
596 parts = base_part.get_descendants(include_self=True)
597 return queryset.filter(part__part__in=parts)
598 else:
599 return queryset.filter(part__part=base_part)
601 pending = rest_filters.BooleanFilter(
602 method='filter_pending', label=_('Order Pending')
603 )
605 def filter_pending(self, queryset, name, value):
606 """Filter by "pending" status (order status = pending)."""
607 if str2bool(value): 607 ↛ 609line 607 didn't jump to line 609 because the condition on line 607 was always true
608 return queryset.filter(order__status__in=PurchaseOrderStatusGroups.OPEN)
609 return queryset.exclude(order__status__in=PurchaseOrderStatusGroups.OPEN)
611 received = rest_filters.BooleanFilter(
612 label=_('Items Received'), method='filter_received'
613 )
615 def filter_received(self, queryset, name, value):
616 """Filter by lines which are "received" (or "not" received).
618 A line is considered "received" when received >= quantity
619 """
620 q = Q(received__gte=F('quantity'))
622 if str2bool(value): 622 ↛ 625line 622 didn't jump to line 625 because the condition on line 622 was always true
623 return queryset.filter(q)
624 # Only count "pending" orders
625 return queryset.exclude(q).filter(
626 order__status__in=PurchaseOrderStatusGroups.OPEN
627 )
630class PurchaseOrderLineItemOutputOptions(OutputConfiguration):
631 """Output options for the PurchaseOrderLineItem endpoint."""
633 OPTIONS = [
634 InvenTreeOutputOption('part_detail'),
635 InvenTreeOutputOption('order_detail'),
636 ]
639class PurchaseOrderLineItemMixin(SerializerContextMixin):
640 """Mixin class for PurchaseOrderLineItem endpoints."""
642 queryset = models.PurchaseOrderLineItem.objects.all()
643 serializer_class = serializers.PurchaseOrderLineItemSerializer
645 def get_queryset(self, *args, **kwargs):
646 """Return annotated queryset for this endpoint."""
647 queryset = super().get_queryset(*args, **kwargs)
649 queryset = serializers.PurchaseOrderLineItemSerializer.annotate_queryset(
650 queryset
651 )
653 return queryset
655 def perform_update(self, serializer):
656 """Override the perform_update method to auto-update pricing if required."""
657 super().perform_update(serializer)
659 # possibly auto-update pricing based on the supplier part pricing data
660 if serializer.validated_data.get('auto_pricing', True):
661 serializer.instance.update_pricing()
664class PurchaseOrderLineItemList(
665 PurchaseOrderLineItemMixin,
666 DataExportViewMixin,
667 OutputOptionsMixin,
668 ListCreateDestroyAPIView,
669):
670 """API endpoint for accessing a list of PurchaseOrderLineItem objects.
672 - GET: Return a list of PurchaseOrder Line Item objects
673 - POST: Create a new PurchaseOrderLineItem object
674 """
676 filterset_class = PurchaseOrderLineItemFilter
677 output_options = PurchaseOrderLineItemOutputOptions
679 def create(self, request, *args, **kwargs):
680 """Create or update a new PurchaseOrderLineItem object."""
681 serializer = self.get_serializer(data=request.data)
682 serializer.is_valid(raise_exception=True)
683 data = cast(dict, serializer.validated_data)
685 # possibly merge duplicate items
686 line_item = None
687 if data.get('merge_items', True):
688 with transaction.atomic():
689 # Lock the matching row, so concurrent line creations cannot
690 # both read the same starting quantity (lost update)
691 other_line = (
692 models.PurchaseOrderLineItem.objects
693 .select_for_update()
694 .filter(
695 part=data.get('part'),
696 order=data.get('order'),
697 target_date=data.get('target_date'),
698 destination=data.get('destination'),
699 )
700 .first()
701 )
703 if other_line is not None:
704 other_line.quantity += Decimal(data.get('quantity', 0))
705 other_line.save()
707 line_item = other_line
709 # otherwise create a new line item
710 if line_item is None:
711 line_item = serializer.save()
713 # possibly auto-update pricing based on the supplier part pricing data
714 if data.get('auto_pricing', True) and isinstance(
715 line_item, models.PurchaseOrderLineItem
716 ):
717 line_item.update_pricing()
719 serializer = serializers.PurchaseOrderLineItemSerializer(line_item)
720 headers = self.get_success_headers(serializer.data)
721 return Response(
722 serializer.data, status=status.HTTP_201_CREATED, headers=headers
723 )
725 filter_backends = SEARCH_ORDER_FILTER
727 ordering_field_aliases = {
728 'MPN': 'part__manufacturer_part__MPN',
729 'SKU': 'part__SKU',
730 'IPN': 'part__part__IPN',
731 'part_name': 'part__part__name',
732 'order': 'order__reference',
733 'status': 'order__status',
734 'complete_date': 'order__complete_date',
735 'line': ['line_int', 'line', 'part__SKU'],
736 }
738 ordering_fields = [
739 'MPN',
740 'part_name',
741 'purchase_price',
742 'quantity',
743 'received',
744 'reference',
745 'SKU',
746 'IPN',
747 'total_price',
748 'target_date',
749 'order',
750 'status',
751 'complete_date',
752 'line',
753 ]
755 search_fields = [
756 'part__part__name',
757 'part__part__description',
758 'part__manufacturer_part__MPN',
759 'part__SKU',
760 'reference',
761 ]
764class PurchaseOrderLineItemDetail(
765 PurchaseOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
766):
767 """Detail API endpoint for PurchaseOrderLineItem object."""
769 output_options = PurchaseOrderLineItemOutputOptions
772class PurchaseOrderExtraLineList(
773 GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI
774):
775 """API endpoint for accessing a list of PurchaseOrderExtraLine objects."""
777 queryset = models.PurchaseOrderExtraLine.objects.all()
778 serializer_class = serializers.PurchaseOrderExtraLineSerializer
781class PurchaseOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
782 """API endpoint for detail view of a PurchaseOrderExtraLine object."""
784 queryset = models.PurchaseOrderExtraLine.objects.all()
785 serializer_class = serializers.PurchaseOrderExtraLineSerializer
788class SalesOrderFilter(OrderFilter):
789 """Custom API filters for the SalesOrderList endpoint."""
791 class Meta:
792 """Metaclass options."""
794 model = models.SalesOrder
795 fields = ['customer']
797 include_variants = rest_filters.BooleanFilter(
798 label=_('Include Variants'), method='filter_include_variants'
799 )
801 def filter_include_variants(self, queryset, name, value):
802 """Filter by whether or not to include variants of the selected part.
804 Note:
805 - This filter does nothing by itself, and requires the 'part' filter to be set.
806 - Refer to the 'filter_part' method for more information.
807 """
808 return queryset
810 part = rest_filters.ModelChoiceFilter(
811 queryset=Part.objects.all(), field_name='part', method='filter_part'
812 )
814 @extend_schema_field(OpenApiTypes.INT)
815 def filter_part(self, queryset, name, part):
816 """Filter SalesOrder by selected 'part'.
818 Note:
819 - If 'include_variants' is set to True, then all variants of the selected part will be included.
820 - Otherwise, just filter by the selected part.
821 """
822 include_variants = str2bool(self.data.get('include_variants', False))
824 # Construct a queryset of parts to filter by
825 if include_variants:
826 parts = part.get_descendants(include_self=True)
827 else:
828 parts = Part.objects.filter(pk=part.pk)
830 # Now that we have a queryset of parts, find all the matching sales orders
831 line_items = models.SalesOrderLineItem.objects.filter(part__in=parts)
833 # Generate a list of ID values for the matching sales orders
834 sales_orders = line_items.values_list('order', flat=True).distinct()
836 # Now we have a list of matching IDs, filter the queryset
837 return queryset.filter(pk__in=sales_orders)
839 completed_before = InvenTreeDateFilter(
840 label=_('Completed Before'), field_name='shipment_date', lookup_expr='lt'
841 )
843 completed_after = InvenTreeDateFilter(
844 label=_('Completed After'), field_name='shipment_date', lookup_expr='gt'
845 )
848class SalesOrderMixin(SerializerContextMixin):
849 """Mixin class for SalesOrder endpoints."""
851 queryset = models.SalesOrder.objects.all().prefetch_related(
852 'customer', 'created_by'
853 )
854 serializer_class = serializers.SalesOrderSerializer
856 def get_queryset(self, *args, **kwargs):
857 """Return annotated queryset for this endpoint."""
858 queryset = super().get_queryset(*args, **kwargs)
860 queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
862 return queryset
865class SalesOrderOutputOptions(OutputConfiguration):
866 """Output options for the SalesOrder endpoint."""
868 OPTIONS = [InvenTreeOutputOption('customer_detail')]
871class SalesOrderList(
872 SalesOrderMixin,
873 OrderCreateMixin,
874 DataExportViewMixin,
875 OutputOptionsMixin,
876 ParameterListMixin,
877 ListCreateAPI,
878):
879 """API endpoint for accessing a list of SalesOrder objects.
881 - GET: Return list of SalesOrder objects (with filters)
882 - POST: Create a new SalesOrder
883 """
885 filterset_class = SalesOrderFilter
886 filter_backends = SEARCH_ORDER_FILTER
887 output_options = SalesOrderOutputOptions
889 ordering_field_aliases = {
890 'reference': ['reference_int', 'reference'],
891 'project_code': ['project_code__code'],
892 }
894 filterset_fields = ['customer']
896 ordering_fields = [
897 'creation_date',
898 'created_by',
899 'reference',
900 'customer__name',
901 'customer_reference',
902 'status',
903 'start_date',
904 'target_date',
905 'line_items',
906 'shipment_date',
907 'total_price',
908 'project_code',
909 'updated_at',
910 ]
912 search_fields = [
913 'customer__name',
914 'reference',
915 'description',
916 'customer_reference',
917 'project_code__code',
918 ]
920 ordering = '-reference'
923class SalesOrderDetail(SalesOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
924 """API endpoint for detail view of a SalesOrder object."""
926 output_options = SalesOrderOutputOptions
929class SalesOrderLineItemFilter(LineItemFilter):
930 """Custom filters for SalesOrderLineItemList endpoint."""
932 class Meta:
933 """Metaclass options."""
935 price_field = 'sale_price'
936 model = models.SalesOrderLineItem
937 fields = []
939 order = rest_filters.ModelChoiceFilter(
940 queryset=models.SalesOrder.objects.all(), field_name='order', label=_('Order')
941 )
943 def filter_include_variants(self, queryset, name, value):
944 """Filter by whether or not to include variants of the selected part.
946 Note:
947 - This filter does nothing by itself, and requires the 'part' filter to be set.
948 - Refer to the 'filter_part' method for more information.
949 """
950 return queryset
952 part = rest_filters.ModelChoiceFilter(
953 queryset=Part.objects.all(),
954 field_name='part',
955 label=_('Part'),
956 method='filter_part',
957 )
959 @extend_schema_field(OpenApiTypes.INT)
960 def filter_part(self, queryset, name, part):
961 """Filter SalesOrderLineItem by selected 'part'.
963 Note:
964 - If 'include_variants' is set to True, then all variants of the selected part will be included.
965 - Otherwise, just filter by the selected part.
966 """
967 include_variants = str2bool(self.data.get('include_variants', False))
969 # Construct a queryset of parts to filter by
970 if include_variants:
971 parts = part.get_descendants(include_self=True)
972 else:
973 parts = Part.objects.filter(pk=part.pk)
975 return queryset.filter(part__in=parts)
977 allocated = rest_filters.BooleanFilter(
978 label=_('Allocated'), method='filter_allocated'
979 )
981 def filter_allocated(self, queryset, name, value):
982 """Filter by lines which are 'allocated'.
984 A line is 'allocated' when allocated >= quantity
985 """
986 q = Q(allocated__gte=F('quantity'))
988 if str2bool(value):
989 return queryset.filter(q)
990 return queryset.exclude(q)
992 completed = rest_filters.BooleanFilter(
993 label=_('Completed'), method='filter_completed'
994 )
996 def filter_completed(self, queryset, name, value):
997 """Filter by lines which are "completed".
999 A line is 'completed' when shipped >= quantity
1000 """
1001 q = Q(shipped__gte=F('quantity'))
1003 if str2bool(value): 1003 ↛ 1005line 1003 didn't jump to line 1005 because the condition on line 1003 was always true
1004 return queryset.filter(q)
1005 return queryset.exclude(q)
1007 order_complete = rest_filters.BooleanFilter(
1008 label=_('Order Complete'), method='filter_order_complete'
1009 )
1011 def filter_order_complete(self, queryset, name, value):
1012 """Filter by whether the order is 'complete' or not."""
1013 if str2bool(value):
1014 return queryset.filter(order__status__in=SalesOrderStatusGroups.COMPLETE)
1016 return queryset.exclude(order__status__in=SalesOrderStatusGroups.COMPLETE)
1018 order_outstanding = rest_filters.BooleanFilter(
1019 label=_('Order Outstanding'), method='filter_order_outstanding'
1020 )
1022 def filter_order_outstanding(self, queryset, name, value):
1023 """Filter by whether the order is 'outstanding' or not."""
1024 if str2bool(value):
1025 return queryset.filter(order__status__in=SalesOrderStatusGroups.OPEN)
1027 return queryset.exclude(order__status__in=SalesOrderStatusGroups.OPEN)
1030class SalesOrderLineItemMixin(SerializerContextMixin):
1031 """Mixin class for SalesOrderLineItem endpoints."""
1033 queryset = models.SalesOrderLineItem.objects.all()
1034 serializer_class = serializers.SalesOrderLineItemSerializer
1036 def get_queryset(self, *args, **kwargs):
1037 """Return annotated queryset for this endpoint."""
1038 queryset = super().get_queryset(*args, **kwargs)
1040 queryset = queryset.prefetch_related(
1041 'part',
1042 'allocations',
1043 'allocations__shipment',
1044 'allocations__item__part',
1045 'allocations__item__location',
1046 'order',
1047 )
1049 queryset = serializers.SalesOrderLineItemSerializer.annotate_queryset(queryset)
1051 return queryset
1054class SalesOrderLineItemOutputOptions(OutputConfiguration):
1055 """Output options for the SalesOrderAllocation endpoint."""
1057 OPTIONS = [
1058 InvenTreeOutputOption('part_detail'),
1059 InvenTreeOutputOption('order_detail'),
1060 InvenTreeOutputOption('customer_detail'),
1061 ]
1064class SalesOrderLineItemList(
1065 SalesOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
1066):
1067 """API endpoint for accessing a list of SalesOrderLineItem objects."""
1069 filterset_class = SalesOrderLineItemFilter
1071 filter_backends = SEARCH_ORDER_FILTER
1073 output_options = SalesOrderLineItemOutputOptions
1075 ordering_fields = [
1076 'customer',
1077 'order',
1078 'part',
1079 'IPN',
1080 'part__name',
1081 'quantity',
1082 'allocated',
1083 'shipped',
1084 'reference',
1085 'sale_price',
1086 'target_date',
1087 'line',
1088 ]
1090 ordering_field_aliases = {
1091 'customer': 'order__customer__name',
1092 'part': 'part__name',
1093 'IPN': 'part__IPN',
1094 'order': 'order__reference',
1095 'line': ['line_int', 'line', 'part__name'],
1096 }
1098 search_fields = ['part__name', 'quantity', 'reference']
1101class SalesOrderLineItemDetail(
1102 SalesOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
1103):
1104 """API endpoint for detail view of a SalesOrderLineItem object."""
1106 output_options = SalesOrderLineItemOutputOptions
1109class SalesOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI):
1110 """API endpoint for accessing a list of SalesOrderExtraLine objects."""
1112 queryset = models.SalesOrderExtraLine.objects.all()
1113 serializer_class = serializers.SalesOrderExtraLineSerializer
1116class SalesOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
1117 """API endpoint for detail view of a SalesOrderExtraLine object."""
1119 queryset = models.SalesOrderExtraLine.objects.all()
1120 serializer_class = serializers.SalesOrderExtraLineSerializer
1123class SalesOrderContextMixin:
1124 """Mixin to add sales order object as serializer context variable."""
1126 queryset = models.SalesOrder.objects.all()
1128 def get_serializer_context(self):
1129 """Add the 'order' reference to the serializer context for any classes which inherit this mixin."""
1130 ctx = super().get_serializer_context()
1132 ctx['request'] = self.request
1134 try:
1135 ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
1136 except Exception:
1137 pass
1139 return ctx
1142class SalesOrderHold(SalesOrderContextMixin, CreateAPI):
1143 """API endpoint to place a SalesOrder on hold."""
1145 serializer_class = serializers.SalesOrderHoldSerializer
1148class SalesOrderCancel(SalesOrderContextMixin, CreateAPI):
1149 """API endpoint to cancel a SalesOrder."""
1151 serializer_class = serializers.SalesOrderCancelSerializer
1154class SalesOrderIssue(SalesOrderContextMixin, CreateAPI):
1155 """API endpoint to issue a SalesOrder."""
1157 serializer_class = serializers.SalesOrderIssueSerializer
1160class SalesOrderComplete(SalesOrderContextMixin, CreateAPI):
1161 """API endpoint for manually marking a SalesOrder as "complete"."""
1163 serializer_class = serializers.SalesOrderCompleteSerializer
1166class SalesOrderAllocateSerials(SalesOrderContextMixin, CreateAPI):
1167 """API endpoint to allocation stock items against a SalesOrder, by specifying serial numbers."""
1169 queryset = models.SalesOrder.objects.none()
1170 serializer_class = serializers.SalesOrderSerialAllocationSerializer
1173class SalesOrderAllocate(SalesOrderContextMixin, CreateAPI):
1174 """API endpoint to allocate stock items against a SalesOrder.
1176 - The SalesOrder is specified in the URL
1177 - See the SalesOrderShipmentAllocationSerializer class
1178 """
1180 queryset = models.SalesOrder.objects.none()
1181 serializer_class = serializers.SalesOrderShipmentAllocationSerializer
1184class SalesOrderAutoAllocate(SalesOrderContextMixin, CreateAPI):
1185 """API endpoint to automatically allocate stock against a SalesOrder.
1187 - Offloads work to a background task and returns task detail
1188 """
1190 serializer_class = serializers.SalesOrderAutoAllocationSerializer
1192 @extend_schema(responses={200: common.serializers.TaskDetailSerializer})
1193 def post(self, *args, **kwargs):
1194 """Validate parameters and offload auto-allocation to a background task."""
1195 from InvenTree.tasks import offload_task
1196 from order.tasks import auto_allocate_sales_order
1198 order_obj = self.get_object()
1199 serializer = self.get_serializer(data=self.request.data)
1200 serializer.is_valid(raise_exception=True)
1201 data = serializer.validated_data
1203 # Extract related models from the validated data
1204 location = data.get('location')
1205 exclude_location = data.get('exclude_location')
1206 shipment = data.get('shipment')
1207 line_items = data.get('line_items', [])
1209 # Offload to the background worker
1210 # Note: We provide the model ID values, not the model instances
1211 task_id = offload_task(
1212 auto_allocate_sales_order,
1213 order_obj.pk,
1214 location_id=location.pk if location else None,
1215 exclude_location_id=exclude_location.pk if exclude_location else None,
1216 shipment_id=shipment.pk if shipment else None,
1217 line_ids=[item.pk for item in line_items] if line_items else None,
1218 interchangeable=data['interchangeable'],
1219 stock_sort_by=data['stock_sort_by'],
1220 serialized_stock=data['serialized_stock'],
1221 group='sales_order',
1222 )
1224 response = common.serializers.TaskDetailSerializer.from_task(task_id).data
1225 return Response(response, status=response['http_status'])
1228class SalesOrderAllocationFilter(FilterSet):
1229 """Custom filterset for the SalesOrderAllocationList endpoint."""
1231 class Meta:
1232 """Metaclass options."""
1234 model = models.SalesOrderAllocation
1235 fields = ['shipment', 'line', 'item']
1237 order = rest_filters.ModelChoiceFilter(
1238 queryset=models.SalesOrder.objects.all(),
1239 field_name='line__order',
1240 label=_('Order'),
1241 )
1243 include_variants = rest_filters.BooleanFilter(
1244 label=_('Include Variants'), method='filter_include_variants'
1245 )
1247 def filter_include_variants(self, queryset, name, value):
1248 """Filter by whether or not to include variants of the selected part.
1250 Note:
1251 - This filter does nothing by itself, and requires the 'part' filter to be set.
1252 - Refer to the 'filter_part' method for more information.
1253 """
1254 return queryset
1256 part = rest_filters.ModelChoiceFilter(
1257 queryset=Part.objects.all(), method='filter_part', label=_('Part')
1258 )
1260 @extend_schema_field(rest_framework.serializers.IntegerField(help_text=_('Part')))
1261 def filter_part(self, queryset, name, part):
1262 """Filter by the 'part' attribute.
1264 Note:
1265 - If "include_variants" is True, include all variants of the selected part
1266 - Otherwise, just filter by the selected part
1267 """
1268 include_variants = str2bool(self.data.get('include_variants', False))
1270 if include_variants:
1271 parts = part.get_descendants(include_self=True)
1272 return queryset.filter(item__part__in=parts)
1273 else:
1274 return queryset.filter(item__part=part)
1276 outstanding = rest_filters.BooleanFilter(
1277 label=_('Outstanding'), method='filter_outstanding'
1278 )
1280 def filter_outstanding(self, queryset, name, value):
1281 """Filter by "outstanding" status (boolean)."""
1282 if str2bool(value):
1283 return queryset.filter(
1284 line__order__status__in=SalesOrderStatusGroups.OPEN,
1285 shipment__shipment_date=None,
1286 )
1287 return queryset.exclude(
1288 shipment__shipment_date=None,
1289 line__order__status__in=SalesOrderStatusGroups.OPEN,
1290 )
1292 assigned_to_shipment = rest_filters.BooleanFilter(
1293 label=_('Has Shipment'), method='filter_assigned_to_shipment'
1294 )
1296 def filter_assigned_to_shipment(self, queryset, name, value):
1297 """Filter by whether or not the allocation has been assigned to a shipment."""
1298 if str2bool(value):
1299 return queryset.exclude(shipment=None)
1300 return queryset.filter(shipment=None)
1302 location = rest_filters.ModelChoiceFilter(
1303 queryset=stock_models.StockLocation.objects.all(),
1304 label=_('Location'),
1305 method='filter_location',
1306 )
1308 @extend_schema_field(
1309 rest_framework.serializers.IntegerField(help_text=_('Location'))
1310 )
1311 def filter_location(self, queryset, name, location):
1312 """Filter by the location of the allocated StockItem."""
1313 locations = location.get_descendants(include_self=True)
1314 return queryset.filter(item__location__in=locations)
1317class SalesOrderAllocationMixin:
1318 """Mixin class for SalesOrderAllocation endpoints."""
1320 queryset = models.SalesOrderAllocation.objects.all()
1321 serializer_class = serializers.SalesOrderAllocationSerializer
1323 def get_queryset(self, *args, **kwargs):
1324 """Annotate the queryset for this endpoint."""
1325 queryset = super().get_queryset(*args, **kwargs)
1327 queryset = queryset.prefetch_related(
1328 'item',
1329 'item__sales_order',
1330 'item__part',
1331 'line__part',
1332 'item__location',
1333 'line__order',
1334 'line__order__responsible',
1335 'line__order__project_code',
1336 'line__order__project_code__responsible',
1337 'shipment',
1338 'shipment__order',
1339 'shipment__checked_by',
1340 ).select_related('line__part__pricing_data', 'item__part__pricing_data')
1342 return queryset
1345class SalesOrderAllocationOutputOptions(OutputConfiguration):
1346 """Output options for the SalesOrderAllocation endpoint."""
1348 OPTIONS = [
1349 InvenTreeOutputOption('part_detail'),
1350 InvenTreeOutputOption('item_detail'),
1351 InvenTreeOutputOption('order_detail'),
1352 InvenTreeOutputOption('location_detail'),
1353 InvenTreeOutputOption('customer_detail'),
1354 ]
1357class SalesOrderAllocationList(
1358 SalesOrderAllocationMixin,
1359 BulkDeleteMixin,
1360 BulkUpdateMixin,
1361 OutputOptionsMixin,
1362 ListAPI,
1363):
1364 """API endpoint for listing SalesOrderAllocation objects."""
1366 filterset_class = SalesOrderAllocationFilter
1367 filter_backends = SEARCH_ORDER_FILTER
1368 output_options = SalesOrderAllocationOutputOptions
1370 def filter_delete_queryset(self, queryset, request):
1371 """Prevent deletion of allocations that have already been shipped."""
1372 return queryset.filter(shipment__shipment_date__isnull=True)
1374 ordering_fields = [
1375 'quantity',
1376 'part',
1377 'serial',
1378 'IPN',
1379 'batch',
1380 'location',
1381 'order',
1382 'shipment_date',
1383 ]
1385 ordering_field_aliases = {
1386 'IPN': 'item__part__IPN',
1387 'part': 'item__part__name',
1388 'serial': ['item__serial_int', 'item__serial'],
1389 'batch': 'item__batch',
1390 'location': 'item__location__name',
1391 'order': 'line__order__reference',
1392 'shipment_date': 'shipment__shipment_date',
1393 }
1395 search_fields = {
1396 'item__part__name',
1397 'item__part__IPN',
1398 'item__serial',
1399 'item__batch',
1400 }
1403class SalesOrderAllocationDetail(SalesOrderAllocationMixin, RetrieveUpdateDestroyAPI):
1404 """API endpoint for detail view of a SalesOrderAllocation object."""
1407class SalesOrderShipmentFilter(FilterSet):
1408 """Custom filterset for the SalesOrderShipmentList endpoint."""
1410 class Meta:
1411 """Metaclass options."""
1413 model = models.SalesOrderShipment
1414 fields = ['order']
1416 checked = rest_filters.BooleanFilter(label='checked', method='filter_checked')
1418 def filter_checked(self, queryset, name, value):
1419 """Filter SalesOrderShipment list by 'checked' status (boolean)."""
1420 if str2bool(value):
1421 return queryset.exclude(checked_by=None)
1422 return queryset.filter(checked_by=None)
1424 shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped')
1426 def filter_shipped(self, queryset, name, value):
1427 """Filter SalesOrder list by 'shipped' status (boolean)."""
1428 if str2bool(value):
1429 return queryset.exclude(shipment_date=None)
1430 return queryset.filter(shipment_date=None)
1432 delivered = rest_filters.BooleanFilter(label='delivered', method='filter_delivered')
1434 def filter_delivered(self, queryset, name, value):
1435 """Filter SalesOrder list by 'delivered' status (boolean)."""
1436 if str2bool(value):
1437 return queryset.exclude(delivery_date=None)
1438 return queryset.filter(delivery_date=None)
1440 order_outstanding = rest_filters.BooleanFilter(
1441 label=_('Order Outstanding'), method='filter_order_outstanding'
1442 )
1444 def filter_order_outstanding(self, queryset, name, value):
1445 """Filter by whether the order is 'outstanding' or not."""
1446 if str2bool(value):
1447 return queryset.filter(order__status__in=SalesOrderStatusGroups.OPEN)
1448 return queryset.exclude(order__status__in=SalesOrderStatusGroups.OPEN)
1450 order_status = rest_filters.NumberFilter(
1451 label=_('Order Status'), method='filter_order_status'
1452 )
1454 def filter_order_status(self, queryset, name, value):
1455 """Filter by linked SalesOrder status."""
1456 q1 = Q(order__status=value, order__status_custom_key__isnull=True)
1457 q2 = Q(order__status_custom_key=value)
1459 return queryset.filter(q1 | q2).distinct()
1461 tags = common.filters.TagsFilter()
1464class SalesOrderShipmentMixin:
1465 """Mixin class for SalesOrderShipment endpoints."""
1467 queryset = models.SalesOrderShipment.objects.all()
1468 serializer_class = serializers.SalesOrderShipmentSerializer
1470 def get_queryset(self, *args, **kwargs):
1471 """Return annotated queryset for this endpoint."""
1472 queryset = super().get_queryset(*args, **kwargs)
1474 queryset = serializers.SalesOrderShipmentSerializer.annotate_queryset(queryset)
1476 return queryset
1479class SalesOrderShipmentList(SalesOrderShipmentMixin, ListCreateAPI):
1480 """API list endpoint for SalesOrderShipment model."""
1482 filterset_class = SalesOrderShipmentFilter
1483 filter_backends = SEARCH_ORDER_FILTER
1484 ordering_fields = ['reference', 'delivery_date', 'shipment_date', 'allocated_items']
1486 search_fields = [
1487 'order__reference',
1488 'reference',
1489 'tracking_number',
1490 'invoice_number',
1491 ]
1494class SalesOrderShipmentDetail(SalesOrderShipmentMixin, RetrieveUpdateDestroyAPI):
1495 """API detail endpoint for SalesOrderShipment model."""
1498class SalesOrderShipmentComplete(CreateAPI):
1499 """API endpoint for completing (shipping) a SalesOrderShipment."""
1501 queryset = models.SalesOrderShipment.objects.all()
1502 serializer_class = serializers.SalesOrderShipmentCompleteSerializer
1504 def get_shipment(self):
1505 """Return the shipment associated with this endpoint."""
1506 try:
1507 shipment = models.SalesOrderShipment.objects.get(
1508 pk=self.kwargs.get('pk', None)
1509 )
1510 except (ValueError, models.SalesOrderShipment.DoesNotExist):
1511 raise NotFound(detail=_('Shipment not found'))
1513 return shipment
1515 def get_serializer_context(self):
1516 """Pass the request object to the serializer."""
1517 ctx = super().get_serializer_context()
1518 ctx['request'] = self.request
1519 ctx['shipment'] = self.get_shipment()
1521 return ctx
1523 @extend_schema(responses={200: common.serializers.TaskDetailSerializer})
1524 def post(self, request, *args, **kwargs):
1525 """Override the post method to handle shipment completion."""
1526 shipment = self.get_shipment()
1528 serializer = self.get_serializer(shipment, data=request.data, partial=True)
1529 serializer.is_valid(raise_exception=True)
1530 data = serializer.validated_data
1532 task_id = shipment.complete_shipment(
1533 request.user,
1534 tracking_number=data.get('tracking_number', shipment.tracking_number),
1535 invoice_number=data.get('invoice_number', shipment.invoice_number),
1536 link=data.get('link', shipment.link),
1537 shipment_date=data.get('shipment_date', None) or current_date(),
1538 delivery_date=data.get('delivery_date', shipment.delivery_date),
1539 )
1541 response = common.serializers.TaskDetailSerializer.from_task(task_id).data
1542 return Response(response, status=response['http_status'])
1545class ReturnOrderFilter(OrderFilter):
1546 """Custom API filters for the ReturnOrderList endpoint."""
1548 class Meta:
1549 """Metaclass options."""
1551 model = models.ReturnOrder
1552 fields = ['customer']
1554 include_variants = rest_filters.BooleanFilter(
1555 label=_('Include Variants'), method='filter_include_variants'
1556 )
1558 def filter_include_variants(self, queryset, name, value):
1559 """Filter by whether or not to include variants of the selected part.
1561 Note:
1562 - This filter does nothing by itself, and requires the 'part' filter to be set.
1563 - Refer to the 'filter_part' method for more information.
1564 """
1565 return queryset
1567 part = rest_filters.ModelChoiceFilter(
1568 queryset=Part.objects.all(), field_name='part', method='filter_part'
1569 )
1571 @extend_schema_field(OpenApiTypes.INT)
1572 def filter_part(self, queryset, name, part):
1573 """Filter by selected 'part'.
1575 Note:
1576 - If 'include_variants' is set to True, then all variants of the selected part will be included.
1577 - Otherwise, just filter by the selected part.
1578 """
1579 include_variants = str2bool(self.data.get('include_variants', False))
1581 if include_variants:
1582 parts = part.get_descendants(include_self=True)
1583 else:
1584 parts = Part.objects.filter(pk=part.pk)
1586 # Now that we have a queryset of parts, find all the matching return orders
1587 line_items = models.ReturnOrderLineItem.objects.filter(item__part__in=parts)
1589 # Generate a list of ID values for the matching return orders
1590 return_orders = line_items.values_list('order', flat=True).distinct()
1592 # Now we have a list of matching IDs, filter the queryset
1593 return queryset.filter(pk__in=return_orders)
1595 completed_before = InvenTreeDateFilter(
1596 label=_('Completed Before'), field_name='complete_date', lookup_expr='lt'
1597 )
1599 completed_after = InvenTreeDateFilter(
1600 label=_('Completed After'), field_name='complete_date', lookup_expr='gt'
1601 )
1604class ReturnOrderMixin(SerializerContextMixin):
1605 """Mixin class for ReturnOrder endpoints."""
1607 queryset = models.ReturnOrder.objects.all()
1608 serializer_class = serializers.ReturnOrderSerializer
1610 def get_queryset(self, *args, **kwargs):
1611 """Return annotated queryset for this endpoint."""
1612 queryset = super().get_queryset(*args, **kwargs)
1613 queryset = serializers.ReturnOrderSerializer.annotate_queryset(queryset)
1614 queryset = queryset.prefetch_related(
1615 'contact', 'created_by', 'customer', 'responsible'
1616 )
1618 return queryset
1621class ReturnOrderOutputOptions(OutputConfiguration):
1622 """Output options for the ReturnOrder endpoint."""
1624 OPTIONS = [InvenTreeOutputOption(flag='customer_detail')]
1627class ReturnOrderList(
1628 ReturnOrderMixin,
1629 OrderCreateMixin,
1630 DataExportViewMixin,
1631 OutputOptionsMixin,
1632 ParameterListMixin,
1633 ListCreateAPI,
1634):
1635 """API endpoint for accessing a list of ReturnOrder objects."""
1637 filterset_class = ReturnOrderFilter
1638 filter_backends = SEARCH_ORDER_FILTER
1640 output_options = ReturnOrderOutputOptions
1642 ordering_field_aliases = {
1643 'reference': ['reference_int', 'reference'],
1644 'project_code': ['project_code__code'],
1645 }
1647 ordering_fields = [
1648 'creation_date',
1649 'created_by',
1650 'reference',
1651 'customer__name',
1652 'customer_reference',
1653 'line_items',
1654 'status',
1655 'start_date',
1656 'target_date',
1657 'complete_date',
1658 'project_code',
1659 'updated_at',
1660 ]
1662 search_fields = [
1663 'customer__name',
1664 'reference',
1665 'description',
1666 'customer_reference',
1667 'project_code__code',
1668 ]
1670 ordering = '-reference'
1673class ReturnOrderDetail(ReturnOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
1674 """API endpoint for detail view of a single ReturnOrder object."""
1676 output_options = ReturnOrderOutputOptions
1679class ReturnOrderContextMixin:
1680 """Simple mixin class to add a ReturnOrder to the serializer context."""
1682 queryset = models.ReturnOrder.objects.all()
1684 def get_serializer_context(self):
1685 """Add the PurchaseOrder object to the serializer context."""
1686 context = super().get_serializer_context()
1688 # Pass the ReturnOrder instance through to the serializer for validation
1689 try:
1690 context['order'] = models.ReturnOrder.objects.get(
1691 pk=self.kwargs.get('pk', None)
1692 )
1693 except Exception:
1694 pass
1696 context['request'] = self.request
1698 return context
1701class ReturnOrderCancel(ReturnOrderContextMixin, CreateAPI):
1702 """API endpoint to cancel a ReturnOrder."""
1704 serializer_class = serializers.ReturnOrderCancelSerializer
1707class ReturnOrderHold(ReturnOrderContextMixin, CreateAPI):
1708 """API endpoint to hold a ReturnOrder."""
1710 serializer_class = serializers.ReturnOrderHoldSerializer
1713class ReturnOrderComplete(ReturnOrderContextMixin, CreateAPI):
1714 """API endpoint to complete a ReturnOrder."""
1716 serializer_class = serializers.ReturnOrderCompleteSerializer
1719class ReturnOrderIssue(ReturnOrderContextMixin, CreateAPI):
1720 """API endpoint to issue (place) a ReturnOrder."""
1722 serializer_class = serializers.ReturnOrderIssueSerializer
1725class ReturnOrderReceive(ReturnOrderContextMixin, CreateAPI):
1726 """API endpoint to receive items against a ReturnOrder."""
1728 queryset = models.ReturnOrder.objects.none()
1729 serializer_class = serializers.ReturnOrderReceiveSerializer
1732class ReturnOrderLineItemFilter(LineItemFilter):
1733 """Custom filters for the ReturnOrderLineItemList endpoint."""
1735 class Meta:
1736 """Metaclass options."""
1738 price_field = 'price'
1739 model = models.ReturnOrderLineItem
1740 fields = ['order', 'item']
1742 outcome = rest_filters.NumberFilter(label='outcome')
1744 received = rest_filters.BooleanFilter(label='received', method='filter_received')
1746 def filter_received(self, queryset, name, value):
1747 """Filter by 'received' field."""
1748 if str2bool(value):
1749 return queryset.exclude(received_date=None)
1750 return queryset.filter(received_date=None)
1753class ReturnOrderLineItemMixin(SerializerContextMixin):
1754 """Mixin class for ReturnOrderLineItem endpoints."""
1756 queryset = models.ReturnOrderLineItem.objects.all()
1757 serializer_class = serializers.ReturnOrderLineItemSerializer
1759 def get_queryset(self, *args, **kwargs):
1760 """Return annotated queryset for this endpoint."""
1761 queryset = super().get_queryset(*args, **kwargs)
1763 queryset = queryset.prefetch_related('order', 'item', 'item__part')
1765 return queryset
1768class ReturnOrderLineItemOutputOptions(OutputConfiguration):
1769 """Output options for the ReturnOrderLineItem endpoint."""
1771 OPTIONS = [
1772 InvenTreeOutputOption('part_detail'),
1773 InvenTreeOutputOption('item_detail', default=True),
1774 InvenTreeOutputOption('order_detail'),
1775 ]
1778class ReturnOrderLineItemList(
1779 ReturnOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
1780):
1781 """API endpoint for accessing a list of ReturnOrderLineItemList objects."""
1783 filterset_class = ReturnOrderLineItemFilter
1785 filter_backends = SEARCH_ORDER_FILTER
1787 output_options = ReturnOrderLineItemOutputOptions
1789 ordering_fields = [
1790 'part',
1791 'IPN',
1792 'stock',
1793 'reference',
1794 'target_date',
1795 'received_date',
1796 'line',
1797 ]
1799 ordering_field_aliases = {
1800 'line': ['line_int', 'line', 'item__part__name'],
1801 'part': 'item__part__name',
1802 'IPN': 'item__part__IPN',
1803 'stock': ['item__quantity', 'item__serial_int', 'item__serial'],
1804 }
1806 search_fields = [
1807 'item__serial',
1808 'item__part__name',
1809 'item__part__description',
1810 'reference',
1811 ]
1814class ReturnOrderLineItemDetail(
1815 ReturnOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
1816):
1817 """API endpoint for detail view of a ReturnOrderLineItem object."""
1819 output_options = ReturnOrderLineItemOutputOptions
1822class ReturnOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI):
1823 """API endpoint for accessing a list of ReturnOrderExtraLine objects."""
1825 queryset = models.ReturnOrderExtraLine.objects.all()
1826 serializer_class = serializers.ReturnOrderExtraLineSerializer
1829class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
1830 """API endpoint for detail view of a ReturnOrderExtraLine object."""
1832 queryset = models.ReturnOrderExtraLine.objects.all()
1833 serializer_class = serializers.ReturnOrderExtraLineSerializer
1836class TransferOrderFilter(OrderFilter):
1837 """Custom API filters for the TransferOrderList endpoint."""
1839 class Meta:
1840 """Metaclass options."""
1842 model = models.TransferOrder
1843 fields = []
1845 include_variants = rest_filters.BooleanFilter(
1846 label=_('Include Variants'), method='filter_include_variants'
1847 )
1849 def filter_include_variants(self, queryset, name, value):
1850 """Filter by whether or not to include variants of the selected part.
1852 Note:
1853 - This filter does nothing by itself, and requires the 'part' filter to be set.
1854 - Refer to the 'filter_part' method for more information.
1855 """
1856 return queryset
1858 part = rest_filters.ModelChoiceFilter(
1859 queryset=Part.objects.all(), field_name='part', method='filter_part'
1860 )
1862 @extend_schema_field(OpenApiTypes.INT)
1863 def filter_part(self, queryset, name, part):
1864 """Filter by selected 'part'.
1866 Note:
1867 - If 'include_variants' is set to True, then all variants of the selected part will be included.
1868 - Otherwise, just filter by the selected part.
1869 """
1870 include_variants = str2bool(self.data.get('include_variants', False))
1872 if include_variants:
1873 parts = part.get_descendants(include_self=True)
1874 else:
1875 parts = Part.objects.filter(pk=part.pk)
1877 # Now that we have a queryset of parts, find all the matching return orders
1878 line_items = models.TransferOrderLineItem.objects.filter(part__in=parts)
1880 # Generate a list of ID values for the matching transfer orders
1881 transfer_orders = line_items.values_list('order', flat=True).distinct()
1883 # Now we have a list of matching IDs, filter the queryset
1884 return queryset.filter(pk__in=transfer_orders)
1886 completed_before = InvenTreeDateFilter(
1887 label=_('Completed Before'), field_name='complete_date', lookup_expr='lt'
1888 )
1890 completed_after = InvenTreeDateFilter(
1891 label=_('Completed After'), field_name='complete_date', lookup_expr='gt'
1892 )
1895class TransferOrderMixin(SerializerContextMixin):
1896 """Mixin class for TransferOrder endpoints."""
1898 queryset = models.TransferOrder.objects.all()
1899 serializer_class = serializers.TransferOrderSerializer
1901 def get_queryset(self, *args, **kwargs):
1902 """Return annotated queryset for this endpoint."""
1903 queryset = super().get_queryset(*args, **kwargs)
1904 queryset = serializers.TransferOrderSerializer.annotate_queryset(queryset)
1905 queryset = queryset.prefetch_related('created_by', 'responsible')
1907 return queryset
1910class TransferOrderList(
1911 TransferOrderMixin,
1912 OrderCreateMixin,
1913 DataExportViewMixin,
1914 OutputOptionsMixin,
1915 ParameterListMixin,
1916 ListCreateAPI,
1917):
1918 """API endpoint for accessing a list of TransferOrder objects."""
1920 filterset_class = TransferOrderFilter
1921 filter_backends = SEARCH_ORDER_FILTER
1923 # TODO:
1924 # output_options = TransferOrderOutputOptions
1926 ordering_field_aliases = {
1927 'reference': ['reference_int', 'reference'],
1928 'project_code': ['project_code__code'],
1929 }
1931 ordering_fields = [
1932 'creation_date',
1933 'created_by',
1934 'reference',
1935 'line_items',
1936 'status',
1937 'start_date',
1938 'target_date',
1939 'complete_date',
1940 'project_code',
1941 ]
1943 search_fields = ['reference', 'description', 'project_code__code']
1945 ordering = '-reference'
1948class TransferOrderDetail(
1949 TransferOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
1950):
1951 """API endpoint for detail view of a single TransferOrder object."""
1953 # output_options = TransferOrderOutputOptions
1956class TransferOrderContextMixin:
1957 """Simple mixin class to add a TransferOrder to the serializer context."""
1959 queryset = models.TransferOrder.objects.all()
1961 def get_serializer_context(self):
1962 """Add the TransferOrder object to the serializer context."""
1963 context = super().get_serializer_context()
1965 # Pass the Transfer instance through to the serializer for validation
1966 try:
1967 context['order'] = models.TransferOrder.objects.get(
1968 pk=self.kwargs.get('pk', None)
1969 )
1970 except Exception:
1971 pass
1973 context['request'] = self.request
1975 return context
1978class TransferOrderCancel(TransferOrderContextMixin, CreateAPI):
1979 """API endpoint to cancel a TransferOrder."""
1981 serializer_class = serializers.TransferOrderCancelSerializer
1984class TransferOrderHold(TransferOrderContextMixin, CreateAPI):
1985 """API endpoint to hold a TransferOrder."""
1987 serializer_class = serializers.TransferOrderHoldSerializer
1990class TransferOrderComplete(TransferOrderContextMixin, CreateAPI):
1991 """API endpoint to complete a TransferOrder."""
1993 serializer_class = serializers.TransferOrderCompleteSerializer
1996class TransferOrderIssue(TransferOrderContextMixin, CreateAPI):
1997 """API endpoint to issue a Transfer Order."""
1999 serializer_class = serializers.TransferOrderIssueSerializer
2002class TransferOrderAllocateSerials(TransferOrderContextMixin, CreateAPI):
2003 """API endpoint to allocation stock items against a TransferOrder, by specifying serial numbers."""
2005 queryset = models.TransferOrder.objects.none()
2006 serializer_class = serializers.TransferOrderSerialAllocationSerializer
2009class TransferOrderAllocate(TransferOrderContextMixin, CreateAPI):
2010 """API endpoint to allocate stock items against a TransferOrder.
2012 - The TransferOrder is specified in the URL
2013 - See the TransferOrderAllocationSerializer class
2014 """
2016 queryset = models.TransferOrder.objects.none()
2017 serializer_class = serializers.TransferOrderLineItemAllocationSerializer
2020class TransferOrderAllocationFilter(FilterSet):
2021 """Custom filterset for the TransferOrderAllocationList endpoint."""
2023 class Meta:
2024 """Metaclass options."""
2026 model = models.TransferOrderAllocation
2027 fields = ['line', 'item']
2029 order = rest_filters.ModelChoiceFilter(
2030 queryset=models.TransferOrder.objects.all(),
2031 field_name='line__order',
2032 label=_('Order'),
2033 )
2035 include_variants = rest_filters.BooleanFilter(
2036 label=_('Include Variants'), method='filter_include_variants'
2037 )
2039 def filter_include_variants(self, queryset, name, value):
2040 """Filter by whether or not to include variants of the selected part.
2042 Note:
2043 - This filter does nothing by itself, and requires the 'part' filter to be set.
2044 - Refer to the 'filter_part' method for more information.
2045 """
2046 return queryset
2048 part = rest_filters.ModelChoiceFilter(
2049 queryset=Part.objects.all(), method='filter_part', label=_('Part')
2050 )
2052 @extend_schema_field(rest_framework.serializers.IntegerField(help_text=_('Part')))
2053 def filter_part(self, queryset, name, part):
2054 """Filter by the 'part' attribute.
2056 Note:
2057 - If "include_variants" is True, include all variants of the selected part
2058 - Otherwise, just filter by the selected part
2059 """
2060 include_variants = str2bool(self.data.get('include_variants', False))
2062 if include_variants:
2063 parts = part.get_descendants(include_self=True)
2064 return queryset.filter(item__part__in=parts)
2065 else:
2066 return queryset.filter(item__part=part)
2068 outstanding = rest_filters.BooleanFilter(
2069 label=_('Outstanding'), method='filter_outstanding'
2070 )
2072 def filter_outstanding(self, queryset, name, value):
2073 """Filter by "outstanding" status (boolean)."""
2074 if str2bool(value):
2075 return queryset.filter(
2076 line__order__status__in=TransferOrderStatusGroups.OPEN
2077 # TODO: is there an additional filter here if we aren't using a "shipment"
2078 # shipment__shipment_date=None,
2079 )
2080 return queryset.exclude(
2081 # TODO: is there an additional filter here if we aren't using a "shipment"
2082 # shipment__shipment_date=None,
2083 line__order__status__in=TransferOrderStatusGroups.OPEN
2084 )
2086 location = rest_filters.ModelChoiceFilter(
2087 queryset=stock_models.StockLocation.objects.all(),
2088 label=_('Location'),
2089 method='filter_location',
2090 )
2092 @extend_schema_field(
2093 rest_framework.serializers.IntegerField(help_text=_('Location'))
2094 )
2095 def filter_location(self, queryset, name, location):
2096 """Filter by the location of the allocated StockItem."""
2097 locations = location.get_descendants(include_self=True)
2098 return queryset.filter(item__location__in=locations)
2101class TransferOrderAllocationMixin:
2102 """Mixin class for TransferOrderAllocation endpoints."""
2104 queryset = models.TransferOrderAllocation.objects.all()
2105 serializer_class = serializers.TransferOrderAllocationSerializer
2107 def get_queryset(self, *args, **kwargs):
2108 """Annotate the queryset for this endpoint."""
2109 queryset = super().get_queryset(*args, **kwargs)
2111 queryset = queryset.prefetch_related(
2112 'item',
2113 'item__sales_order',
2114 'item__part',
2115 'line__part',
2116 'item__location',
2117 'line__order',
2118 'line__order__responsible',
2119 'line__order__project_code',
2120 'line__order__project_code__responsible',
2121 ).select_related('line__part__pricing_data', 'item__part__pricing_data')
2123 return queryset
2126class TransferOrderAllocationOutputOptions(OutputConfiguration):
2127 """Output options for the TransferOrderAllocation endpoint."""
2129 OPTIONS = [
2130 InvenTreeOutputOption('part_detail'),
2131 InvenTreeOutputOption('item_detail'),
2132 InvenTreeOutputOption('order_detail'),
2133 InvenTreeOutputOption('location_detail'),
2134 ]
2137class TransferOrderAllocationList(
2138 TransferOrderAllocationMixin, BulkUpdateMixin, OutputOptionsMixin, ListAPI
2139):
2140 """API endpoint for listing TransferOrderAllocation objects."""
2142 filterset_class = TransferOrderAllocationFilter
2143 filter_backends = SEARCH_ORDER_FILTER
2144 output_options = TransferOrderAllocationOutputOptions
2146 ordering_fields = [
2147 'quantity',
2148 'part',
2149 'serial',
2150 'IPN',
2151 'batch',
2152 'location',
2153 'order',
2154 ]
2156 ordering_field_aliases = {
2157 'IPN': 'item__part__IPN',
2158 'part': 'item__part__name',
2159 'serial': ['item__serial_int', 'item__serial'],
2160 'batch': 'item__batch',
2161 'location': 'item__location__name',
2162 'order': 'line__order__reference',
2163 }
2165 search_fields = {
2166 'item__part__name',
2167 'item__part__IPN',
2168 'item__serial',
2169 'item__batch',
2170 }
2173class TransferOrderAllocationDetail(
2174 TransferOrderAllocationMixin, RetrieveUpdateDestroyAPI
2175):
2176 """API endpoint for detail view of a TransferOrderAllocation object."""
2179class TransferOrderLineItemFilter(LineItemFilter):
2180 """Custom filters for TransferOrderLineItemList endpoint."""
2182 class Meta:
2183 """Metaclass options."""
2185 model = models.TransferOrderLineItem
2186 fields = []
2188 order = rest_filters.ModelChoiceFilter(
2189 queryset=models.TransferOrder.objects.all(),
2190 field_name='order',
2191 label=_('Order'),
2192 )
2194 def filter_include_variants(self, queryset, name, value):
2195 """Filter by whether or not to include variants of the selected part.
2197 Note:
2198 - This filter does nothing by itself, and requires the 'part' filter to be set.
2199 - Refer to the 'filter_part' method for more information.
2200 """
2201 return queryset
2203 part = rest_filters.ModelChoiceFilter(
2204 queryset=Part.objects.all(),
2205 field_name='part',
2206 label=_('Part'),
2207 method='filter_part',
2208 )
2210 @extend_schema_field(OpenApiTypes.INT)
2211 def filter_part(self, queryset, name, part):
2212 """Filter TransferOrderLineItem by selected 'part'.
2214 Note:
2215 - If 'include_variants' is set to True, then all variants of the selected part will be included.
2216 - Otherwise, just filter by the selected part.
2217 """
2218 include_variants = str2bool(self.data.get('include_variants', False))
2220 # Construct a queryset of parts to filter by
2221 if include_variants:
2222 parts = part.get_descendants(include_self=True)
2223 else:
2224 parts = Part.objects.filter(pk=part.pk)
2226 return queryset.filter(part__in=parts)
2228 allocated = rest_filters.BooleanFilter(
2229 label=_('Allocated'), method='filter_allocated'
2230 )
2232 def filter_allocated(self, queryset, name, value):
2233 """Filter by lines which are 'allocated'.
2235 A line is 'allocated' when allocated >= quantity
2236 """
2237 q = Q(allocated__gte=F('quantity'))
2239 if str2bool(value):
2240 return queryset.filter(q)
2241 return queryset.exclude(q)
2243 completed = rest_filters.BooleanFilter(
2244 label=_('Completed'), method='filter_completed'
2245 )
2247 def filter_completed(self, queryset, name, value):
2248 """Filter by lines which are "completed".
2250 A line is 'completed' when transferred >= quantity
2251 """
2252 q = Q(transferred__gte=F('quantity'))
2254 if str2bool(value): 2254 ↛ 2256line 2254 didn't jump to line 2256 because the condition on line 2254 was always true
2255 return queryset.filter(q)
2256 return queryset.exclude(q)
2258 order_complete = rest_filters.BooleanFilter(
2259 label=_('Order Complete'), method='filter_order_complete'
2260 )
2262 def filter_order_complete(self, queryset, name, value):
2263 """Filter by whether the order is 'complete' or not."""
2264 if str2bool(value):
2265 return queryset.filter(order__status__in=TransferOrderStatusGroups.COMPLETE)
2267 return queryset.exclude(order__status__in=TransferOrderStatusGroups.COMPLETE)
2269 order_outstanding = rest_filters.BooleanFilter(
2270 label=_('Order Outstanding'), method='filter_order_outstanding'
2271 )
2273 def filter_order_outstanding(self, queryset, name, value):
2274 """Filter by whether the order is 'outstanding' or not."""
2275 if str2bool(value):
2276 return queryset.filter(order__status__in=TransferOrderStatusGroups.OPEN)
2278 return queryset.exclude(order__status__in=TransferOrderStatusGroups.OPEN)
2281class TransferOrderLineItemMixin(SerializerContextMixin):
2282 """Mixin class for TransferOrderLineItem endpoints."""
2284 queryset = models.TransferOrderLineItem.objects.all()
2285 serializer_class = serializers.TransferOrderLineItemSerializer
2287 def get_queryset(self, *args, **kwargs):
2288 """Return annotated queryset for this endpoint."""
2289 queryset = super().get_queryset(*args, **kwargs)
2291 queryset = queryset.prefetch_related(
2292 'part',
2293 'allocations',
2294 # 'allocations__transfer',
2295 'allocations__item__part',
2296 'allocations__item__location',
2297 'order',
2298 )
2300 queryset = serializers.TransferOrderLineItemSerializer.annotate_queryset(
2301 queryset
2302 )
2304 return queryset
2307class TransferOrderLineItemOutputOptions(OutputConfiguration):
2308 """Output options for the TransferOrderAllocation endpoint."""
2310 OPTIONS = [
2311 InvenTreeOutputOption('part_detail'),
2312 InvenTreeOutputOption('order_detail'),
2313 ]
2316class TransferOrderLineItemList(
2317 TransferOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
2318):
2319 """API endpoint for accessing a list of TransferOrderLineItem objects."""
2321 filterset_class = TransferOrderLineItemFilter
2323 filter_backends = SEARCH_ORDER_FILTER
2325 output_options = TransferOrderLineItemOutputOptions
2327 ordering_fields = [
2328 'order',
2329 'part',
2330 'part__name',
2331 'quantity',
2332 'allocated',
2333 'transferred',
2334 'reference',
2335 'target_date',
2336 ]
2338 ordering_field_aliases = {'part': 'part__name', 'order': 'order__reference'}
2340 search_fields = ['part__name', 'quantity', 'reference']
2343class TransferOrderLineItemDetail(
2344 TransferOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
2345):
2346 """API endpoint for detail view of a TransferOrderLineItem object."""
2348 output_options = TransferOrderLineItemOutputOptions
2351class OrderCalendarExport(ICalFeed):
2352 """Calendar export for Purchase/Sales Orders.
2354 Optional parameters:
2355 - include_completed: true/false
2356 whether or not to show completed orders. Defaults to false
2357 """
2359 instance_url = get_base_url()
2361 instance_url = instance_url.replace('http://', '').replace('https://', '')
2362 timezone = settings.TIME_ZONE
2363 file_name = 'calendar.ics'
2365 def __call__(self, request, *args, **kwargs):
2366 """Overload call in order to check for authentication.
2368 This is required to force Django to look for the authentication,
2369 otherwise login request with Basic auth via curl or similar are ignored,
2370 and login via a calendar client will not work.
2372 See:
2373 https://stackoverflow.com/questions/3817694/django-rss-feed-authentication
2374 https://stackoverflow.com/questions/152248/can-i-use-http-basic-authentication-with-django
2375 https://www.djangosnippets.org/snippets/243/
2376 """
2377 import base64
2379 if request.user.is_authenticated:
2380 # Authenticated on first try - maybe normal browser call?
2381 return super().__call__(request, *args, **kwargs)
2383 # No login yet - check in headers
2384 if 'authorization' in request.headers:
2385 auth = request.headers['authorization'].split()
2386 if len(auth) == 2:
2387 # NOTE: We are only support basic authentication for now.
2388 #
2389 if auth[0].lower() == 'basic':
2390 uname, passwd = base64.b64decode(auth[1]).decode('ascii').split(':')
2391 user = authenticate(username=uname, password=passwd)
2392 if user is not None and user.is_active:
2393 login(request, user)
2394 request.user = user
2396 # Check again
2397 if request.user.is_authenticated:
2398 # Authenticated after second try
2399 return super().__call__(request, *args, **kwargs)
2401 # Still nothing - return Unauth. header with info on how to authenticate
2402 # Information is needed by client, eg Thunderbird
2403 response = JsonResponse({
2404 'detail': 'Authentication credentials were not provided.'
2405 })
2406 response['WWW-Authenticate'] = 'Basic realm="api"'
2407 response.status_code = 401
2408 return response
2410 def get_object(self, request, *args, **kwargs):
2411 """This is where settings from the URL etc will be obtained."""
2412 # Help:
2413 # https://django.readthedocs.io/en/stable/ref/contrib/syndication.html
2415 obj = {}
2416 obj['ordertype'] = kwargs['ordertype']
2417 obj['include_completed'] = bool(request.GET.get('include_completed', False))
2419 return obj
2421 def title(self, obj):
2422 """Return calendar title."""
2423 if obj['ordertype'] == 'purchase-order':
2424 ordertype_title = _('Purchase Order')
2425 elif obj['ordertype'] == 'sales-order':
2426 ordertype_title = _('Sales Order')
2427 elif obj['ordertype'] == 'return-order':
2428 ordertype_title = _('Return Order')
2429 elif obj['ordertype'] == 'transfer-order':
2430 ordertype_title = _('Transfer Order')
2431 else:
2432 ordertype_title = _('Unknown')
2434 company_name = common.settings.get_global_setting('INVENTREE_COMPANY_NAME')
2436 return f'{company_name} {ordertype_title}'
2438 def product_id(self, obj):
2439 """Return calendar product id."""
2440 return f'//{self.instance_url}//{self.title(obj)}//EN'
2442 def items(self, obj):
2443 """Return a list of Orders.
2445 Filters:
2446 - Only return those which have a target_date set
2447 - Only return incomplete orders, unless include_completed is set to True
2448 """
2449 if obj['ordertype'] == 'purchase-order':
2450 if obj['include_completed'] is False:
2451 # Do not include completed orders from list in this case
2452 # Completed status = 30
2453 outlist = models.PurchaseOrder.objects.filter(
2454 target_date__isnull=False
2455 ).filter(status__lt=PurchaseOrderStatus.COMPLETE.value)
2456 else:
2457 outlist = models.PurchaseOrder.objects.filter(target_date__isnull=False)
2458 elif obj['ordertype'] == 'sales-order':
2459 if obj['include_completed'] is False:
2460 # Do not include completed (=shipped) orders from list in this case
2461 # Shipped status = 20
2462 outlist = models.SalesOrder.objects.filter(
2463 target_date__isnull=False
2464 ).filter(status__lt=SalesOrderStatus.SHIPPED.value)
2465 else:
2466 outlist = models.SalesOrder.objects.filter(target_date__isnull=False)
2467 elif obj['ordertype'] == 'return-order':
2468 if obj['include_completed'] is False:
2469 # Do not include completed orders from list in this case
2470 # Complete status = 30
2471 outlist = models.ReturnOrder.objects.filter(
2472 target_date__isnull=False
2473 ).filter(status__lt=ReturnOrderStatus.COMPLETE.value)
2474 else:
2475 outlist = models.ReturnOrder.objects.filter(target_date__isnull=False)
2476 elif obj['ordertype'] == 'transfer-order':
2477 if obj['include_completed'] is False:
2478 # Do not include completed orders from list in this case
2479 # Complete status = 30
2480 outlist = models.TransferOrder.objects.filter(
2481 target_date__isnull=False
2482 ).filter(status__lt=TransferOrderStatus.COMPLETE.value)
2483 else:
2484 outlist = models.TransferOrder.objects.filter(target_date__isnull=False)
2485 else:
2486 outlist = []
2488 return outlist
2490 def item_title(self, item):
2491 """Set the event title to the order reference."""
2492 return f'{item.reference}'
2494 def item_description(self, item):
2495 """Set the event description."""
2496 if hasattr(item, 'company') and item.company:
2497 return f'Company: {item.company.name}\nStatus: {item.get_status_display()}\nDescription: {item.description}'
2498 else:
2499 return (
2500 f'Status: {item.get_status_display()}\nDescription: {item.description}'
2501 )
2503 def item_start_datetime(self, item):
2504 """Set event start to target date. Goal is all-day event."""
2505 return item.target_date
2507 def item_end_datetime(self, item):
2508 """Set event end to target date. Goal is all-day event."""
2509 return item.target_date
2511 def item_created(self, item):
2512 """Use creation date of PO as creation date of event."""
2513 return item.creation_date
2515 def item_class(self, item):
2516 """Set item class to PUBLIC."""
2517 return 'PUBLIC'
2519 def item_guid(self, item):
2520 """Return globally unique UID for event."""
2521 return f'po_{item.pk}_{item.reference.replace(" ", "-")}@{self.instance_url}'
2523 def item_link(self, item):
2524 """Set the item link."""
2525 return construct_absolute_url(item.get_absolute_url())
2528order_api_urls = [
2529 # API endpoints for purchase orders
2530 path(
2531 'po/',
2532 include([
2533 # Individual purchase order detail URLs
2534 path(
2535 '<int:pk>/',
2536 include([
2537 path(
2538 'cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'
2539 ),
2540 path('hold/', PurchaseOrderHold.as_view(), name='api-po-hold'),
2541 path(
2542 'complete/',
2543 PurchaseOrderComplete.as_view(),
2544 name='api-po-complete',
2545 ),
2546 path('issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
2547 meta_path(models.PurchaseOrder),
2548 path(
2549 'receive/',
2550 PurchaseOrderReceive.as_view(),
2551 name='api-po-receive',
2552 ),
2553 # PurchaseOrder detail API endpoint
2554 path('', PurchaseOrderDetail.as_view(), name='api-po-detail'),
2555 ]),
2556 ),
2557 # Purchase order status code information
2558 path(
2559 'status/',
2560 StatusView.as_view(),
2561 {StatusView.MODEL_REF: PurchaseOrderStatus},
2562 name='api-po-status-codes',
2563 ),
2564 # Purchase order list
2565 path('', PurchaseOrderList.as_view(), name='api-po-list'),
2566 ]),
2567 ),
2568 # API endpoints for purchase order line items
2569 path(
2570 'po-line/',
2571 include([
2572 path(
2573 '<int:pk>/',
2574 include([
2575 meta_path(models.PurchaseOrderLineItem),
2576 path(
2577 '',
2578 PurchaseOrderLineItemDetail.as_view(),
2579 name='api-po-line-detail',
2580 ),
2581 ]),
2582 ),
2583 path('', PurchaseOrderLineItemList.as_view(), name='api-po-line-list'),
2584 ]),
2585 ),
2586 # API endpoints for purchase order extra line
2587 path(
2588 'po-extra-line/',
2589 include([
2590 path(
2591 '<int:pk>/',
2592 include([
2593 meta_path(models.PurchaseOrderExtraLine),
2594 path(
2595 '',
2596 PurchaseOrderExtraLineDetail.as_view(),
2597 name='api-po-extra-line-detail',
2598 ),
2599 ]),
2600 ),
2601 path(
2602 '', PurchaseOrderExtraLineList.as_view(), name='api-po-extra-line-list'
2603 ),
2604 ]),
2605 ),
2606 # API endpoints for sales orders
2607 path(
2608 'so/',
2609 include([
2610 path(
2611 'shipment/',
2612 include([
2613 path(
2614 '<int:pk>/',
2615 include([
2616 path(
2617 'ship/',
2618 SalesOrderShipmentComplete.as_view(),
2619 name='api-so-shipment-ship',
2620 ),
2621 meta_path(models.SalesOrderShipment),
2622 path(
2623 '',
2624 SalesOrderShipmentDetail.as_view(),
2625 name='api-so-shipment-detail',
2626 ),
2627 ]),
2628 ),
2629 path(
2630 '',
2631 SalesOrderShipmentList.as_view(),
2632 name='api-so-shipment-list',
2633 ),
2634 ]),
2635 ),
2636 # Sales order detail view
2637 path(
2638 '<int:pk>/',
2639 include([
2640 path(
2641 'allocate/',
2642 SalesOrderAllocate.as_view(),
2643 name='api-so-allocate',
2644 ),
2645 path(
2646 'allocate-serials/',
2647 SalesOrderAllocateSerials.as_view(),
2648 name='api-so-allocate-serials',
2649 ),
2650 path(
2651 'auto-allocate/',
2652 SalesOrderAutoAllocate.as_view(),
2653 name='api-so-auto-allocate',
2654 ),
2655 path('hold/', SalesOrderHold.as_view(), name='api-so-hold'),
2656 path('cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
2657 path('issue/', SalesOrderIssue.as_view(), name='api-so-issue'),
2658 path(
2659 'complete/',
2660 SalesOrderComplete.as_view(),
2661 name='api-so-complete',
2662 ),
2663 meta_path(models.SalesOrder),
2664 # SalesOrder detail endpoint
2665 path('', SalesOrderDetail.as_view(), name='api-so-detail'),
2666 ]),
2667 ),
2668 # Sales order status code information
2669 path(
2670 'status/',
2671 StatusView.as_view(),
2672 {StatusView.MODEL_REF: SalesOrderStatus},
2673 name='api-so-status-codes',
2674 ),
2675 # Sales order list view
2676 path('', SalesOrderList.as_view(), name='api-so-list'),
2677 ]),
2678 ),
2679 # API endpoints for sales order line items
2680 path(
2681 'so-line/',
2682 include([
2683 path(
2684 '<int:pk>/',
2685 include([
2686 meta_path(models.SalesOrderLineItem),
2687 path(
2688 '',
2689 SalesOrderLineItemDetail.as_view(),
2690 name='api-so-line-detail',
2691 ),
2692 ]),
2693 ),
2694 path('', SalesOrderLineItemList.as_view(), name='api-so-line-list'),
2695 ]),
2696 ),
2697 # API endpoints for sales order extra line
2698 path(
2699 'so-extra-line/',
2700 include([
2701 path(
2702 '<int:pk>/',
2703 include([
2704 meta_path(models.SalesOrderExtraLine),
2705 path(
2706 '',
2707 SalesOrderExtraLineDetail.as_view(),
2708 name='api-so-extra-line-detail',
2709 ),
2710 ]),
2711 ),
2712 path('', SalesOrderExtraLineList.as_view(), name='api-so-extra-line-list'),
2713 ]),
2714 ),
2715 # API endpoints for sales order allocations
2716 path(
2717 'so-allocation/',
2718 include([
2719 path(
2720 '<int:pk>/',
2721 SalesOrderAllocationDetail.as_view(),
2722 name='api-so-allocation-detail',
2723 ),
2724 path('', SalesOrderAllocationList.as_view(), name='api-so-allocation-list'),
2725 ]),
2726 ),
2727 # API endpoints for return orders
2728 path(
2729 'ro/',
2730 include([
2731 # Return Order detail endpoints
2732 path(
2733 '<int:pk>/',
2734 include([
2735 path(
2736 'cancel/',
2737 ReturnOrderCancel.as_view(),
2738 name='api-return-order-cancel',
2739 ),
2740 path('hold/', ReturnOrderHold.as_view(), name='api-ro-hold'),
2741 path(
2742 'complete/',
2743 ReturnOrderComplete.as_view(),
2744 name='api-return-order-complete',
2745 ),
2746 path(
2747 'issue/',
2748 ReturnOrderIssue.as_view(),
2749 name='api-return-order-issue',
2750 ),
2751 path(
2752 'receive/',
2753 ReturnOrderReceive.as_view(),
2754 name='api-return-order-receive',
2755 ),
2756 meta_path(models.ReturnOrder),
2757 path(
2758 '', ReturnOrderDetail.as_view(), name='api-return-order-detail'
2759 ),
2760 ]),
2761 ),
2762 # Return order status code information
2763 path(
2764 'status/',
2765 StatusView.as_view(),
2766 {StatusView.MODEL_REF: ReturnOrderStatus},
2767 name='api-return-order-status-codes',
2768 ),
2769 # Return Order list
2770 path('', ReturnOrderList.as_view(), name='api-return-order-list'),
2771 ]),
2772 ),
2773 # API endpoints for return order lines
2774 path(
2775 'ro-line/',
2776 include([
2777 path(
2778 '<int:pk>/',
2779 include([
2780 meta_path(models.ReturnOrderLineItem),
2781 path(
2782 '',
2783 ReturnOrderLineItemDetail.as_view(),
2784 name='api-return-order-line-detail',
2785 ),
2786 ]),
2787 ),
2788 # Return order line item status code information
2789 path(
2790 'status/',
2791 StatusView.as_view(),
2792 {StatusView.MODEL_REF: ReturnOrderLineStatus},
2793 name='api-return-order-line-status-codes',
2794 ),
2795 path(
2796 '', ReturnOrderLineItemList.as_view(), name='api-return-order-line-list'
2797 ),
2798 ]),
2799 ),
2800 # API endpoints for return order extra line
2801 path(
2802 'ro-extra-line/',
2803 include([
2804 path(
2805 '<int:pk>/',
2806 include([
2807 meta_path(models.ReturnOrderExtraLine),
2808 path(
2809 '',
2810 ReturnOrderExtraLineDetail.as_view(),
2811 name='api-return-order-extra-line-detail',
2812 ),
2813 ]),
2814 ),
2815 path(
2816 '',
2817 ReturnOrderExtraLineList.as_view(),
2818 name='api-return-order-extra-line-list',
2819 ),
2820 ]),
2821 ),
2822 # API endpoints for transfer orders
2823 path(
2824 'transfer-order/',
2825 include([
2826 # Transfer Order detail endpoints
2827 path(
2828 '<int:pk>/',
2829 include([
2830 path(
2831 'allocate/',
2832 TransferOrderAllocate.as_view(),
2833 name='api-transfer-order-allocate',
2834 ),
2835 path(
2836 'allocate-serials/',
2837 TransferOrderAllocateSerials.as_view(),
2838 name='api-transfer-order-allocate-serials',
2839 ),
2840 path(
2841 'cancel/',
2842 TransferOrderCancel.as_view(),
2843 name='api-transfer-order-cancel',
2844 ),
2845 path(
2846 'hold/',
2847 TransferOrderHold.as_view(),
2848 name='api-transfer-order-hold',
2849 ),
2850 path(
2851 'complete/',
2852 TransferOrderComplete.as_view(),
2853 name='api-transfer-order-complete',
2854 ),
2855 path(
2856 'issue/',
2857 TransferOrderIssue.as_view(),
2858 name='api-transfer-order-issue',
2859 ),
2860 meta_path(models.TransferOrder),
2861 path(
2862 '',
2863 TransferOrderDetail.as_view(),
2864 name='api-transfer-order-detail',
2865 ),
2866 ]),
2867 ),
2868 # Transfer Order list
2869 path('', TransferOrderList.as_view(), name='api-transfer-order-list'),
2870 ]),
2871 ),
2872 # API endpoints for transfer order line items
2873 path(
2874 'transfer-order-line/',
2875 include([
2876 path(
2877 '<int:pk>/',
2878 include([
2879 meta_path(models.TransferOrderLineItem),
2880 path(
2881 '',
2882 TransferOrderLineItemDetail.as_view(),
2883 name='api-transfer-order-line-detail',
2884 ),
2885 ]),
2886 ),
2887 path(
2888 '',
2889 TransferOrderLineItemList.as_view(),
2890 name='api-transfer-order-line-list',
2891 ),
2892 ]),
2893 ),
2894 # API endpoints for sales order allocations
2895 path(
2896 'transfer-order-allocation/',
2897 include([
2898 path(
2899 '<int:pk>/',
2900 TransferOrderAllocationDetail.as_view(),
2901 name='api-transfer-order-allocation-detail',
2902 ),
2903 path(
2904 '',
2905 TransferOrderAllocationList.as_view(),
2906 name='api-transfer-order-allocation-list',
2907 ),
2908 ]),
2909 ),
2910 # API endpoint for subscribing to ICS calendar of purchase/sales/return orders
2911 re_path(
2912 r'^calendar/(?P<ordertype>purchase-order|sales-order|return-order|transfer-order)/calendar.ics',
2913 OrderCalendarExport(),
2914 name='api-po-so-calendar',
2915 ),
2916]