• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

inventree / InvenTree / 4548642839

pending completion
4548642839

push

github

GitHub
[Feature] Add RMA support (#4488)

1082 of 1082 new or added lines in 49 files covered. (100.0%)

26469 of 30045 relevant lines covered (88.1%)

0.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.82
/InvenTree/order/api.py
1
"""JSON API for the Order app."""
2

3
from django.contrib.auth import authenticate, login
1✔
4
from django.db import transaction
1✔
5
from django.db.models import F, Q
1✔
6
from django.db.utils import ProgrammingError
1✔
7
from django.http.response import JsonResponse
1✔
8
from django.urls import include, path, re_path
1✔
9
from django.utils.translation import gettext_lazy as _
1✔
10

11
from django_filters import rest_framework as rest_filters
1✔
12
from django_ical.views import ICalFeed
1✔
13
from rest_framework import filters, status
1✔
14
from rest_framework.exceptions import ValidationError
1✔
15
from rest_framework.response import Response
1✔
16

17
import order.models as models
1✔
18
import order.serializers as serializers
1✔
19
from common.models import InvenTreeSetting
1✔
20
from common.settings import settings
1✔
21
from company.models import SupplierPart
1✔
22
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
1✔
23
                           ListCreateDestroyAPIView)
24
from InvenTree.filters import InvenTreeOrderingFilter
1✔
25
from InvenTree.helpers import DownloadFile, str2bool
1✔
26
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI,
1✔
27
                              RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
28
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
1✔
29
from order.admin import (PurchaseOrderExtraLineResource,
1✔
30
                         PurchaseOrderLineItemResource, PurchaseOrderResource,
31
                         ReturnOrderResource, SalesOrderExtraLineResource,
32
                         SalesOrderLineItemResource, SalesOrderResource)
33
from part.models import Part
1✔
34
from plugin.serializers import MetadataSerializer
1✔
35
from users.models import Owner
1✔
36

37

38
class GeneralExtraLineList(APIDownloadMixin):
1✔
39
    """General template for ExtraLine API classes."""
40

41
    def get_serializer(self, *args, **kwargs):
1✔
42
        """Return the serializer instance for this endpoint"""
43
        try:
1✔
44
            params = self.request.query_params3
1✔
45

46
            kwargs['order_detail'] = str2bool(params.get('order_detail', False))
×
47
        except AttributeError:
1✔
48
            pass
1✔
49

50
        kwargs['context'] = self.get_serializer_context()
1✔
51

52
        return self.serializer_class(*args, **kwargs)
1✔
53

54
    def get_queryset(self, *args, **kwargs):
1✔
55
        """Return the annotated queryset for this endpoint"""
56
        queryset = super().get_queryset(*args, **kwargs)
1✔
57

58
        queryset = queryset.prefetch_related(
1✔
59
            'order',
60
        )
61

62
        return queryset
1✔
63

64
    filter_backends = [
1✔
65
        rest_filters.DjangoFilterBackend,
66
        filters.SearchFilter,
67
        filters.OrderingFilter
68
    ]
69

70
    ordering_fields = [
1✔
71
        'title',
72
        'quantity',
73
        'note',
74
        'reference',
75
    ]
76

77
    search_fields = [
1✔
78
        'title',
79
        'quantity',
80
        'note',
81
        'reference'
82
    ]
83

84
    filterset_fields = [
1✔
85
        'order',
86
    ]
87

88

89
class OrderFilter(rest_filters.FilterSet):
1✔
90
    """Base class for custom API filters for the OrderList endpoint."""
91

92
    # Filter against order status
93
    status = rest_filters.NumberFilter(label="Order Status", method='filter_status')
1✔
94

95
    def filter_status(self, queryset, name, value):
1✔
96
        """Filter by integer status code"""
97

98
        return queryset.filter(status=value)
1✔
99

100
    # Exact match for reference
101
    reference = rest_filters.CharFilter(
1✔
102
        label='Filter by exact reference',
103
        field_name='reference',
104
        lookup_expr="iexact"
105
    )
106

107
    assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
1✔
108

109
    def filter_assigned_to_me(self, queryset, name, value):
1✔
110
        """Filter by orders which are assigned to the current user."""
111

112
        # Work out who "me" is!
113
        owners = Owner.get_owners_matching_user(self.request.user)
1✔
114

115
        if str2bool(value):
1✔
116
            return queryset.filter(responsible__in=owners)
1✔
117
        else:
118
            return queryset.exclude(responsible__in=owners)
1✔
119

120
    overdue = rest_filters.BooleanFilter(label='overdue', method='filter_overdue')
1✔
121

122
    def filter_overdue(self, queryset, name, value):
1✔
123
        """Generic filter for determining if an order is 'overdue'.
124

125
        Note that the overdue_filter() classmethod must be defined for the model
126
        """
127

128
        if str2bool(value):
1✔
129
            return queryset.filter(self.Meta.model.overdue_filter())
1✔
130
        else:
131
            return queryset.exclude(self.Meta.model.overdue_filter())
1✔
132

133
    outstanding = rest_filters.BooleanFilter(label='outstanding', method='filter_outstanding')
1✔
134

135
    def filter_outstanding(self, queryset, name, value):
1✔
136
        """Generic filter for determining if an order is 'outstanding'"""
137

138
        if str2bool(value):
1✔
139
            return queryset.filter(status__in=self.Meta.model.get_status_class().OPEN)
1✔
140
        else:
141
            return queryset.exclude(status__in=self.Meta.model.get_status_class().OPEN)
1✔
142

143

144
class LineItemFilter(rest_filters.FilterSet):
1✔
145
    """Base class for custom API filters for order line item list(s)"""
146

147
    # Filter by order status
148
    order_status = rest_filters.NumberFilter(label='order_status', field_name='order__status')
1✔
149

150
    has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method='filter_has_pricing')
1✔
151

152
    def filter_has_pricing(self, queryset, name, value):
1✔
153
        """Filter by whether or not the line item has pricing information"""
154
        filters = {self.Meta.price_field: None}
1✔
155

156
        if str2bool(value):
1✔
157
            return queryset.exclude(**filters)
1✔
158
        else:
159
            return queryset.filter(**filters)
1✔
160

161

162
class PurchaseOrderFilter(OrderFilter):
1✔
163
    """Custom API filters for the PurchaseOrderList endpoint."""
164

165
    class Meta:
1✔
166
        """Metaclass options."""
167

168
        model = models.PurchaseOrder
1✔
169
        fields = [
1✔
170
            'supplier',
171
        ]
172

173

174
class PurchaseOrderMixin:
1✔
175
    """Mixin class for PurchaseOrder endpoints"""
176

177
    queryset = models.PurchaseOrder.objects.all()
1✔
178
    serializer_class = serializers.PurchaseOrderSerializer
1✔
179

180
    def get_serializer(self, *args, **kwargs):
1✔
181
        """Return the serializer instance for this endpoint"""
182
        try:
1✔
183
            kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', False))
1✔
184
        except AttributeError:
1✔
185
            pass
1✔
186

187
        # Ensure the request context is passed through
188
        kwargs['context'] = self.get_serializer_context()
1✔
189

190
        return self.serializer_class(*args, **kwargs)
1✔
191

192
    def get_queryset(self, *args, **kwargs):
1✔
193
        """Return the annotated queryset for this endpoint"""
194
        queryset = super().get_queryset(*args, **kwargs)
1✔
195

196
        queryset = queryset.prefetch_related(
1✔
197
            'supplier',
198
            'lines',
199
        )
200

201
        queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset)
1✔
202

203
        return queryset
1✔
204

205

206
class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI):
1✔
207
    """API endpoint for accessing a list of PurchaseOrder objects.
208

209
    - GET: Return list of PurchaseOrder objects (with filters)
210
    - POST: Create a new PurchaseOrder object
211
    """
212

213
    filterset_class = PurchaseOrderFilter
1✔
214

215
    def create(self, request, *args, **kwargs):
1✔
216
        """Save user information on create."""
217

218
        data = self.clean_data(request.data)
1✔
219

220
        duplicate_order = data.pop('duplicate_order', None)
1✔
221
        duplicate_line_items = str2bool(data.pop('duplicate_line_items', False))
1✔
222
        duplicate_extra_lines = str2bool(data.pop('duplicate_extra_lines', False))
1✔
223

224
        if duplicate_order is not None:
1✔
225
            try:
1✔
226
                duplicate_order = models.PurchaseOrder.objects.get(pk=duplicate_order)
1✔
227
            except (ValueError, models.PurchaseOrder.DoesNotExist):
1✔
228
                raise ValidationError({
1✔
229
                    'duplicate_order': [_('No matching purchase order found')],
230
                })
231

232
        serializer = self.get_serializer(data=data)
1✔
233
        serializer.is_valid(raise_exception=True)
1✔
234

235
        with transaction.atomic():
1✔
236
            order = serializer.save()
1✔
237
            order.created_by = request.user
1✔
238
            order.save()
1✔
239

240
            # Duplicate line items from other order if required
241
            if duplicate_order is not None:
1✔
242

243
                if duplicate_line_items:
1✔
244
                    for line in duplicate_order.lines.all():
1✔
245
                        # Copy the line across to the new order
246
                        line.pk = None
1✔
247
                        line.order = order
1✔
248
                        line.received = 0
1✔
249

250
                        line.save()
1✔
251

252
                if duplicate_extra_lines:
1✔
253
                    for line in duplicate_order.extra_lines.all():
1✔
254
                        # Copy the line across to the new order
255
                        line.pk = None
1✔
256
                        line.order = order
1✔
257

258
                        line.save()
1✔
259

260
        headers = self.get_success_headers(serializer.data)
1✔
261
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
1✔
262

263
    def download_queryset(self, queryset, export_format):
1✔
264
        """Download the filtered queryset as a file"""
265

266
        dataset = PurchaseOrderResource().export(queryset=queryset)
1✔
267

268
        filedata = dataset.export(export_format)
1✔
269

270
        filename = f"InvenTree_PurchaseOrders.{export_format}"
1✔
271

272
        return DownloadFile(filedata, filename)
1✔
273

274
    def filter_queryset(self, queryset):
1✔
275
        """Custom queryset filtering"""
276
        # Perform basic filtering
277
        queryset = super().filter_queryset(queryset)
1✔
278

279
        params = self.request.query_params
1✔
280

281
        # Attempt to filter by part
282
        part = params.get('part', None)
1✔
283

284
        if part is not None:
1✔
285
            try:
1✔
286
                part = Part.objects.get(pk=part)
1✔
287
                queryset = queryset.filter(id__in=[p.id for p in part.purchase_orders()])
1✔
288
            except (Part.DoesNotExist, ValueError):
×
289
                pass
×
290

291
        # Attempt to filter by supplier part
292
        supplier_part = params.get('supplier_part', None)
1✔
293

294
        if supplier_part is not None:
1✔
295
            try:
1✔
296
                supplier_part = SupplierPart.objects.get(pk=supplier_part)
1✔
297
                queryset = queryset.filter(id__in=[p.id for p in supplier_part.purchase_orders()])
1✔
298
            except (ValueError, SupplierPart.DoesNotExist):
×
299
                pass
×
300

301
        # Filter by 'date range'
302
        min_date = params.get('min_date', None)
1✔
303
        max_date = params.get('max_date', None)
1✔
304

305
        if min_date is not None and max_date is not None:
1✔
306
            queryset = models.PurchaseOrder.filterByDate(queryset, min_date, max_date)
×
307

308
        return queryset
1✔
309

310
    filter_backends = [
1✔
311
        rest_filters.DjangoFilterBackend,
312
        filters.SearchFilter,
313
        InvenTreeOrderingFilter,
314
    ]
315

316
    ordering_field_aliases = {
1✔
317
        'reference': ['reference_int', 'reference'],
318
    }
319

320
    search_fields = [
1✔
321
        'reference',
322
        'supplier__name',
323
        'supplier_reference',
324
        'description',
325
    ]
326

327
    ordering_fields = [
1✔
328
        'creation_date',
329
        'reference',
330
        'supplier__name',
331
        'target_date',
332
        'line_items',
333
        'status',
334
        'responsible',
335
        'total_price',
336
    ]
337

338
    ordering = '-reference'
1✔
339

340

341
class PurchaseOrderDetail(PurchaseOrderMixin, RetrieveUpdateDestroyAPI):
1✔
342
    """API endpoint for detail view of a PurchaseOrder object."""
343
    pass
1✔
344

345

346
class PurchaseOrderContextMixin:
1✔
347
    """Mixin to add purchase order object as serializer context variable."""
348

349
    queryset = models.PurchaseOrder.objects.all()
1✔
350

351
    def get_serializer_context(self):
1✔
352
        """Add the PurchaseOrder object to the serializer context."""
353
        context = super().get_serializer_context()
1✔
354

355
        # Pass the purchase order through to the serializer for validation
356
        try:
1✔
357
            context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
1✔
358
        except Exception:
1✔
359
            pass
1✔
360

361
        context['request'] = self.request
1✔
362

363
        return context
1✔
364

365

366
class PurchaseOrderCancel(PurchaseOrderContextMixin, CreateAPI):
1✔
367
    """API endpoint to 'cancel' a purchase order.
368

369
    The purchase order must be in a state which can be cancelled
370
    """
371

372
    serializer_class = serializers.PurchaseOrderCancelSerializer
1✔
373

374

375
class PurchaseOrderComplete(PurchaseOrderContextMixin, CreateAPI):
1✔
376
    """API endpoint to 'complete' a purchase order."""
377

378
    serializer_class = serializers.PurchaseOrderCompleteSerializer
1✔
379

380

381
class PurchaseOrderIssue(PurchaseOrderContextMixin, CreateAPI):
1✔
382
    """API endpoint to 'issue' (place) a PurchaseOrder."""
383

384
    serializer_class = serializers.PurchaseOrderIssueSerializer
1✔
385

386

387
class PurchaseOrderMetadata(RetrieveUpdateAPI):
1✔
388
    """API endpoint for viewing / updating PurchaseOrder metadata."""
389

390
    def get_serializer(self, *args, **kwargs):
1✔
391
        """Return MetadataSerializer instance for a PurchaseOrder"""
392
        return MetadataSerializer(models.PurchaseOrder, *args, **kwargs)
1✔
393

394
    queryset = models.PurchaseOrder.objects.all()
1✔
395

396

397
class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI):
1✔
398
    """API endpoint to receive stock items against a PurchaseOrder.
399

400
    - The purchase order is specified in the URL.
401
    - Items to receive are specified as a list called "items" with the following options:
402
        - line_item: pk of the PO Line item
403
        - supplier_part: pk value of the supplier part
404
        - quantity: quantity to receive
405
        - status: stock item status
406
        - location: destination for stock item (optional)
407
        - batch_code: the batch code for this stock item
408
        - serial_numbers: serial numbers for this stock item
409
    - 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
410
    """
411

412
    queryset = models.PurchaseOrderLineItem.objects.none()
1✔
413

414
    serializer_class = serializers.PurchaseOrderReceiveSerializer
1✔
415

416

417
class PurchaseOrderLineItemFilter(LineItemFilter):
1✔
418
    """Custom filters for the PurchaseOrderLineItemList endpoint."""
419

420
    class Meta:
1✔
421
        """Metaclass options."""
422
        price_field = 'purchase_price'
1✔
423
        model = models.PurchaseOrderLineItem
1✔
424
        fields = [
1✔
425
            'order',
426
            'part',
427
        ]
428

429
    pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
1✔
430

431
    def filter_pending(self, queryset, name, value):
1✔
432
        """Filter by "pending" status (order status = pending)"""
433

434
        if str2bool(value):
1✔
435
            return queryset.filter(order__status__in=PurchaseOrderStatus.OPEN)
1✔
436
        else:
437
            return queryset.exclude(order__status__in=PurchaseOrderStatus.OPEN)
1✔
438

439
    received = rest_filters.BooleanFilter(label='received', method='filter_received')
1✔
440

441
    def filter_received(self, queryset, name, value):
1✔
442
        """Filter by lines which are "received" (or "not" received)
443

444
        A line is considered "received" when received >= quantity
445
        """
446
        q = Q(received__gte=F('quantity'))
1✔
447

448
        if str2bool(value):
1✔
449
            return queryset.filter(q)
1✔
450
        else:
451
            # Only count "pending" orders
452
            return queryset.exclude(q).filter(order__status__in=PurchaseOrderStatus.OPEN)
1✔
453

454

455
class PurchaseOrderLineItemMixin:
1✔
456
    """Mixin class for PurchaseOrderLineItem endpoints"""
457

458
    queryset = models.PurchaseOrderLineItem.objects.all()
1✔
459
    serializer_class = serializers.PurchaseOrderLineItemSerializer
1✔
460

461
    def get_queryset(self, *args, **kwargs):
1✔
462
        """Return annotated queryset for this endpoint"""
463
        queryset = super().get_queryset(*args, **kwargs)
1✔
464

465
        queryset = serializers.PurchaseOrderLineItemSerializer.annotate_queryset(queryset)
1✔
466

467
        return queryset
1✔
468

469
    def get_serializer(self, *args, **kwargs):
1✔
470
        """Return serializer instance for this endpoint"""
471
        try:
1✔
472
            kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
1✔
473
            kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False))
1✔
474
        except AttributeError:
1✔
475
            pass
1✔
476

477
        kwargs['context'] = self.get_serializer_context()
1✔
478

479
        return self.serializer_class(*args, **kwargs)
1✔
480

481

482
class PurchaseOrderLineItemList(PurchaseOrderLineItemMixin, APIDownloadMixin, ListCreateDestroyAPIView):
1✔
483
    """API endpoint for accessing a list of PurchaseOrderLineItem objects.
484

485
    - GET: Return a list of PurchaseOrder Line Item objects
486
    - POST: Create a new PurchaseOrderLineItem object
487
    """
488

489
    filterset_class = PurchaseOrderLineItemFilter
1✔
490

491
    def filter_queryset(self, queryset):
1✔
492
        """Additional filtering options."""
493
        params = self.request.query_params
1✔
494

495
        queryset = super().filter_queryset(queryset)
1✔
496

497
        base_part = params.get('base_part', None)
1✔
498

499
        if base_part:
1✔
500
            try:
×
501
                base_part = Part.objects.get(pk=base_part)
×
502

503
                queryset = queryset.filter(part__part=base_part)
×
504

505
            except (ValueError, Part.DoesNotExist):
×
506
                pass
×
507

508
        return queryset
1✔
509

510
    def download_queryset(self, queryset, export_format):
1✔
511
        """Download the requested queryset as a file"""
512

513
        dataset = PurchaseOrderLineItemResource().export(queryset=queryset)
1✔
514

515
        filedata = dataset.export(export_format)
1✔
516

517
        filename = f"InvenTree_PurchaseOrderItems.{export_format}"
1✔
518

519
        return DownloadFile(filedata, filename)
1✔
520

521
    filter_backends = [
1✔
522
        rest_filters.DjangoFilterBackend,
523
        filters.SearchFilter,
524
        InvenTreeOrderingFilter
525
    ]
526

527
    ordering_field_aliases = {
1✔
528
        'MPN': 'part__manufacturer_part__MPN',
529
        'SKU': 'part__SKU',
530
        'part_name': 'part__part__name',
531
    }
532

533
    ordering_fields = [
1✔
534
        'MPN',
535
        'part_name',
536
        'purchase_price',
537
        'quantity',
538
        'received',
539
        'reference',
540
        'SKU',
541
        'total_price',
542
        'target_date',
543
    ]
544

545
    search_fields = [
1✔
546
        'part__part__name',
547
        'part__part__description',
548
        'part__manufacturer_part__MPN',
549
        'part__SKU',
550
        'reference',
551
    ]
552

553

554
class PurchaseOrderLineItemDetail(PurchaseOrderLineItemMixin, RetrieveUpdateDestroyAPI):
1✔
555
    """Detail API endpoint for PurchaseOrderLineItem object."""
556
    pass
1✔
557

558

559
class PurchaseOrderLineItemMetadata(RetrieveUpdateAPI):
1✔
560
    """API endpoint for viewing / updating PurchaseOrderLineItem metadata."""
561

562
    def get_serializer(self, *args, **kwargs):
1✔
563
        """Return MetadataSerializer instance for a Company"""
564
        return MetadataSerializer(models.PurchaseOrderLineItem, *args, **kwargs)
1✔
565

566
    queryset = models.PurchaseOrderLineItem.objects.all()
1✔
567

568

569
class PurchaseOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
1✔
570
    """API endpoint for accessing a list of PurchaseOrderExtraLine objects."""
571

572
    queryset = models.PurchaseOrderExtraLine.objects.all()
1✔
573
    serializer_class = serializers.PurchaseOrderExtraLineSerializer
1✔
574

575
    def download_queryset(self, queryset, export_format):
1✔
576
        """Download this queryset as a file"""
577

578
        dataset = PurchaseOrderExtraLineResource().export(queryset=queryset)
×
579
        filedata = dataset.export(export_format)
×
580
        filename = f"InvenTree_ExtraPurchaseOrderLines.{export_format}"
×
581

582
        return DownloadFile(filedata, filename)
×
583

584

585
class PurchaseOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
1✔
586
    """API endpoint for detail view of a PurchaseOrderExtraLine object."""
587

588
    queryset = models.PurchaseOrderExtraLine.objects.all()
1✔
589
    serializer_class = serializers.PurchaseOrderExtraLineSerializer
1✔
590

591

592
class PurchaseOrderExtraLineItemMetadata(RetrieveUpdateAPI):
1✔
593
    """API endpoint for viewing / updating PurchaseOrderExtraLineItem metadata."""
594

595
    def get_serializer(self, *args, **kwargs):
1✔
596
        """Return MetadataSerializer instance"""
597
        return MetadataSerializer(models.PurchaseOrderExtraLine, *args, **kwargs)
1✔
598

599
    queryset = models.PurchaseOrderExtraLine.objects.all()
1✔
600

601

602
class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
1✔
603
    """API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""
604

605
    queryset = models.SalesOrderAttachment.objects.all()
1✔
606
    serializer_class = serializers.SalesOrderAttachmentSerializer
1✔
607

608
    filter_backends = [
1✔
609
        rest_filters.DjangoFilterBackend,
610
    ]
611

612
    filterset_fields = [
1✔
613
        'order',
614
    ]
615

616

617
class SalesOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
1✔
618
    """Detail endpoint for SalesOrderAttachment."""
619

620
    queryset = models.SalesOrderAttachment.objects.all()
1✔
621
    serializer_class = serializers.SalesOrderAttachmentSerializer
1✔
622

623

624
class SalesOrderFilter(OrderFilter):
1✔
625
    """Custom API filters for the SalesOrderList endpoint."""
626

627
    class Meta:
1✔
628
        """Metaclass options."""
629

630
        model = models.SalesOrder
1✔
631
        fields = [
1✔
632
            'customer',
633
        ]
634

635

636
class SalesOrderMixin:
1✔
637
    """Mixin class for SalesOrder endpoints"""
638

639
    queryset = models.SalesOrder.objects.all()
1✔
640
    serializer_class = serializers.SalesOrderSerializer
1✔
641

642
    def get_serializer(self, *args, **kwargs):
1✔
643
        """Return serializer instance for this endpoint"""
644
        try:
1✔
645
            kwargs['customer_detail'] = str2bool(self.request.query_params.get('customer_detail', False))
1✔
646
        except AttributeError:
1✔
647
            pass
1✔
648

649
        # Ensure the context is passed through to the serializer
650
        kwargs['context'] = self.get_serializer_context()
1✔
651

652
        return self.serializer_class(*args, **kwargs)
1✔
653

654
    def get_queryset(self, *args, **kwargs):
1✔
655
        """Return annotated queryset for this endpoint"""
656
        queryset = super().get_queryset(*args, **kwargs)
1✔
657

658
        queryset = queryset.prefetch_related(
1✔
659
            'customer',
660
            'lines'
661
        )
662

663
        queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
1✔
664

665
        return queryset
1✔
666

667

668
class SalesOrderList(SalesOrderMixin, APIDownloadMixin, ListCreateAPI):
1✔
669
    """API endpoint for accessing a list of SalesOrder objects.
670

671
    - GET: Return list of SalesOrder objects (with filters)
672
    - POST: Create a new SalesOrder
673
    """
674

675
    filterset_class = SalesOrderFilter
1✔
676

677
    def create(self, request, *args, **kwargs):
1✔
678
        """Save user information on create."""
679
        serializer = self.get_serializer(data=self.clean_data(request.data))
1✔
680
        serializer.is_valid(raise_exception=True)
1✔
681

682
        item = serializer.save()
1✔
683
        item.created_by = request.user
1✔
684
        item.save()
1✔
685

686
        headers = self.get_success_headers(serializer.data)
1✔
687
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
1✔
688

689
    def download_queryset(self, queryset, export_format):
1✔
690
        """Download this queryset as a file"""
691

692
        dataset = SalesOrderResource().export(queryset=queryset)
1✔
693

694
        filedata = dataset.export(export_format)
1✔
695

696
        filename = f"InvenTree_SalesOrders.{export_format}"
1✔
697

698
        return DownloadFile(filedata, filename)
1✔
699

700
    def filter_queryset(self, queryset):
1✔
701
        """Perform custom filtering operations on the SalesOrder queryset."""
702
        queryset = super().filter_queryset(queryset)
1✔
703

704
        params = self.request.query_params
1✔
705

706
        # Filter by "Part"
707
        # Only return SalesOrder which have LineItem referencing the part
708
        part = params.get('part', None)
1✔
709

710
        if part is not None:
1✔
711
            try:
×
712
                part = Part.objects.get(pk=part)
×
713
                queryset = queryset.filter(id__in=[so.id for so in part.sales_orders()])
×
714
            except (Part.DoesNotExist, ValueError):
×
715
                pass
×
716

717
        # Filter by 'date range'
718
        min_date = params.get('min_date', None)
1✔
719
        max_date = params.get('max_date', None)
1✔
720

721
        if min_date is not None and max_date is not None:
1✔
722
            queryset = models.SalesOrder.filterByDate(queryset, min_date, max_date)
×
723

724
        return queryset
1✔
725

726
    filter_backends = [
1✔
727
        rest_filters.DjangoFilterBackend,
728
        filters.SearchFilter,
729
        InvenTreeOrderingFilter,
730
    ]
731

732
    ordering_field_aliases = {
1✔
733
        'reference': ['reference_int', 'reference'],
734
    }
735

736
    filterset_fields = [
1✔
737
        'customer',
738
    ]
739

740
    ordering_fields = [
1✔
741
        'creation_date',
742
        'reference',
743
        'customer__name',
744
        'customer_reference',
745
        'status',
746
        'target_date',
747
        'line_items',
748
        'shipment_date',
749
        'total_price',
750
    ]
751

752
    search_fields = [
1✔
753
        'customer__name',
754
        'reference',
755
        'description',
756
        'customer_reference',
757
    ]
758

759
    ordering = '-reference'
1✔
760

761

762
class SalesOrderDetail(SalesOrderMixin, RetrieveUpdateDestroyAPI):
1✔
763
    """API endpoint for detail view of a SalesOrder object."""
764
    pass
1✔
765

766

767
class SalesOrderLineItemFilter(LineItemFilter):
1✔
768
    """Custom filters for SalesOrderLineItemList endpoint."""
769

770
    class Meta:
1✔
771
        """Metaclass options."""
772
        price_field = 'sale_price'
1✔
773
        model = models.SalesOrderLineItem
1✔
774
        fields = [
1✔
775
            'order',
776
            'part',
777
        ]
778

779
    completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
1✔
780

781
    def filter_completed(self, queryset, name, value):
1✔
782
        """Filter by lines which are "completed".
783

784
        A line is completed when shipped >= quantity
785
        """
786
        q = Q(shipped__gte=F('quantity'))
1✔
787

788
        if str2bool(value):
1✔
789
            return queryset.filter(q)
1✔
790
        else:
791
            return queryset.exclude(q)
1✔
792

793

794
class SalesOrderLineItemMixin:
1✔
795
    """Mixin class for SalesOrderLineItem endpoints"""
796

797
    queryset = models.SalesOrderLineItem.objects.all()
1✔
798
    serializer_class = serializers.SalesOrderLineItemSerializer
1✔
799

800
    def get_serializer(self, *args, **kwargs):
1✔
801
        """Return serializer for this endpoint with extra data as requested"""
802
        try:
1✔
803
            params = self.request.query_params
1✔
804

805
            kwargs['part_detail'] = str2bool(params.get('part_detail', False))
1✔
806
            kwargs['order_detail'] = str2bool(params.get('order_detail', False))
1✔
807
            kwargs['allocations'] = str2bool(params.get('allocations', False))
1✔
808
            kwargs['customer_detail'] = str2bool(params.get('customer_detail', False))
1✔
809

810
        except AttributeError:
1✔
811
            pass
1✔
812

813
        kwargs['context'] = self.get_serializer_context()
1✔
814

815
        return self.serializer_class(*args, **kwargs)
1✔
816

817
    def get_queryset(self, *args, **kwargs):
1✔
818
        """Return annotated queryset for this endpoint"""
819
        queryset = super().get_queryset(*args, **kwargs)
1✔
820

821
        queryset = queryset.prefetch_related(
1✔
822
            'part',
823
            'part__stock_items',
824
            'allocations',
825
            'allocations__item__location',
826
            'order',
827
            'order__stock_items',
828
        )
829

830
        queryset = serializers.SalesOrderLineItemSerializer.annotate_queryset(queryset)
1✔
831

832
        return queryset
1✔
833

834

835
class SalesOrderLineItemList(SalesOrderLineItemMixin, APIDownloadMixin, ListCreateAPI):
1✔
836
    """API endpoint for accessing a list of SalesOrderLineItem objects."""
837

838
    filterset_class = SalesOrderLineItemFilter
1✔
839

840
    def download_queryset(self, queryset, export_format):
1✔
841
        """Download the requested queryset as a file"""
842

843
        dataset = SalesOrderLineItemResource().export(queryset=queryset)
×
844
        filedata = dataset.export(export_format)
×
845

846
        filename = f"InvenTree_SalesOrderItems.{export_format}"
×
847

848
        return DownloadFile(filedata, filename)
×
849

850
    filter_backends = [
1✔
851
        rest_filters.DjangoFilterBackend,
852
        filters.SearchFilter,
853
        filters.OrderingFilter
854
    ]
855

856
    ordering_fields = [
1✔
857
        'part__name',
858
        'quantity',
859
        'reference',
860
        'target_date',
861
    ]
862

863
    search_fields = [
1✔
864
        'part__name',
865
        'quantity',
866
        'reference',
867
    ]
868

869

870
class SalesOrderLineItemDetail(SalesOrderLineItemMixin, RetrieveUpdateDestroyAPI):
1✔
871
    """API endpoint for detail view of a SalesOrderLineItem object."""
872
    pass
1✔
873

874

875
class SalesOrderLineItemMetadata(RetrieveUpdateAPI):
1✔
876
    """API endpoint for viewing / updating SalesOrderLineItem metadata."""
877

878
    def get_serializer(self, *args, **kwargs):
1✔
879
        """Return MetadataSerializer instance"""
880
        return MetadataSerializer(models.SalesOrderLineItem, *args, **kwargs)
1✔
881

882
    queryset = models.SalesOrderLineItem.objects.all()
1✔
883

884

885
class SalesOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
1✔
886
    """API endpoint for accessing a list of SalesOrderExtraLine objects."""
887

888
    queryset = models.SalesOrderExtraLine.objects.all()
1✔
889
    serializer_class = serializers.SalesOrderExtraLineSerializer
1✔
890

891
    def download_queryset(self, queryset, export_format):
1✔
892
        """Download this queryset as a file"""
893

894
        dataset = SalesOrderExtraLineResource().export(queryset=queryset)
×
895
        filedata = dataset.export(export_format)
×
896
        filename = f"InvenTree_ExtraSalesOrderLines.{export_format}"
×
897

898
        return DownloadFile(filedata, filename)
×
899

900

901
class SalesOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
1✔
902
    """API endpoint for detail view of a SalesOrderExtraLine object."""
903

904
    queryset = models.SalesOrderExtraLine.objects.all()
1✔
905
    serializer_class = serializers.SalesOrderExtraLineSerializer
1✔
906

907

908
class SalesOrderExtraLineItemMetadata(RetrieveUpdateAPI):
1✔
909
    """API endpoint for viewing / updating SalesOrderExtraLineItem metadata."""
910

911
    def get_serializer(self, *args, **kwargs):
1✔
912
        """Return MetadataSerializer instance"""
913
        return MetadataSerializer(models.SalesOrderExtraLine, *args, **kwargs)
1✔
914

915
    queryset = models.SalesOrderExtraLine.objects.all()
1✔
916

917

918
class SalesOrderContextMixin:
1✔
919
    """Mixin to add sales order object as serializer context variable."""
920

921
    def get_serializer_context(self):
1✔
922
        """Add the 'order' reference to the serializer context for any classes which inherit this mixin"""
923
        ctx = super().get_serializer_context()
1✔
924

925
        ctx['request'] = self.request
1✔
926

927
        try:
1✔
928
            ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
1✔
929
        except Exception:
1✔
930
            pass
1✔
931

932
        return ctx
1✔
933

934

935
class SalesOrderCancel(SalesOrderContextMixin, CreateAPI):
1✔
936
    """API endpoint to cancel a SalesOrder"""
937

938
    queryset = models.SalesOrder.objects.all()
1✔
939
    serializer_class = serializers.SalesOrderCancelSerializer
1✔
940

941

942
class SalesOrderComplete(SalesOrderContextMixin, CreateAPI):
1✔
943
    """API endpoint for manually marking a SalesOrder as "complete"."""
944

945
    queryset = models.SalesOrder.objects.all()
1✔
946
    serializer_class = serializers.SalesOrderCompleteSerializer
1✔
947

948

949
class SalesOrderMetadata(RetrieveUpdateAPI):
1✔
950
    """API endpoint for viewing / updating SalesOrder metadata."""
951

952
    def get_serializer(self, *args, **kwargs):
1✔
953
        """Return a metadata serializer for the SalesOrder model"""
954
        return MetadataSerializer(models.SalesOrder, *args, **kwargs)
1✔
955

956
    queryset = models.SalesOrder.objects.all()
1✔
957

958

959
class SalesOrderAllocateSerials(SalesOrderContextMixin, CreateAPI):
1✔
960
    """API endpoint to allocation stock items against a SalesOrder, by specifying serial numbers."""
961

962
    queryset = models.SalesOrder.objects.none()
1✔
963
    serializer_class = serializers.SalesOrderSerialAllocationSerializer
1✔
964

965

966
class SalesOrderAllocate(SalesOrderContextMixin, CreateAPI):
1✔
967
    """API endpoint to allocate stock items against a SalesOrder.
968

969
    - The SalesOrder is specified in the URL
970
    - See the SalesOrderShipmentAllocationSerializer class
971
    """
972

973
    queryset = models.SalesOrder.objects.none()
1✔
974
    serializer_class = serializers.SalesOrderShipmentAllocationSerializer
1✔
975

976

977
class SalesOrderAllocationDetail(RetrieveUpdateDestroyAPI):
1✔
978
    """API endpoint for detali view of a SalesOrderAllocation object."""
979

980
    queryset = models.SalesOrderAllocation.objects.all()
1✔
981
    serializer_class = serializers.SalesOrderAllocationSerializer
1✔
982

983

984
class SalesOrderAllocationList(ListAPI):
1✔
985
    """API endpoint for listing SalesOrderAllocation objects."""
986

987
    queryset = models.SalesOrderAllocation.objects.all()
1✔
988
    serializer_class = serializers.SalesOrderAllocationSerializer
1✔
989

990
    def get_serializer(self, *args, **kwargs):
1✔
991
        """Return the serializer instance for this endpoint.
992

993
        Adds extra detail serializers if requested
994
        """
995
        try:
×
996
            params = self.request.query_params
×
997

998
            kwargs['part_detail'] = str2bool(params.get('part_detail', False))
×
999
            kwargs['item_detail'] = str2bool(params.get('item_detail', False))
×
1000
            kwargs['order_detail'] = str2bool(params.get('order_detail', False))
×
1001
            kwargs['location_detail'] = str2bool(params.get('location_detail', False))
×
1002
            kwargs['customer_detail'] = str2bool(params.get('customer_detail', False))
×
1003
        except AttributeError:
×
1004
            pass
×
1005

1006
        return self.serializer_class(*args, **kwargs)
×
1007

1008
    def filter_queryset(self, queryset):
1✔
1009
        """Custom queryset filtering"""
1010
        queryset = super().filter_queryset(queryset)
×
1011

1012
        # Filter by order
1013
        params = self.request.query_params
×
1014

1015
        # Filter by "part" reference
1016
        part = params.get('part', None)
×
1017

1018
        if part is not None:
×
1019
            queryset = queryset.filter(item__part=part)
×
1020

1021
        # Filter by "order" reference
1022
        order = params.get('order', None)
×
1023

1024
        if order is not None:
×
1025
            queryset = queryset.filter(line__order=order)
×
1026

1027
        # Filter by "stock item"
1028
        item = params.get('item', params.get('stock_item', None))
×
1029

1030
        if item is not None:
×
1031
            queryset = queryset.filter(item=item)
×
1032

1033
        # Filter by "outstanding" order status
1034
        outstanding = params.get('outstanding', None)
×
1035

1036
        if outstanding is not None:
×
1037
            outstanding = str2bool(outstanding)
×
1038

1039
            if outstanding:
×
1040
                # Filter only "open" orders
1041
                # Filter only allocations which have *not* shipped
1042
                queryset = queryset.filter(
×
1043
                    line__order__status__in=SalesOrderStatus.OPEN,
1044
                    shipment__shipment_date=None,
1045
                )
1046
            else:
1047
                queryset = queryset.exclude(
×
1048
                    line__order__status__in=SalesOrderStatus.OPEN,
1049
                    shipment__shipment_date=None
1050
                )
1051

1052
        return queryset
×
1053

1054
    filter_backends = [
1✔
1055
        rest_filters.DjangoFilterBackend,
1056
    ]
1057

1058

1059
class SalesOrderShipmentFilter(rest_filters.FilterSet):
1✔
1060
    """Custom filterset for the SalesOrderShipmentList endpoint."""
1061

1062
    class Meta:
1✔
1063
        """Metaclass options."""
1064

1065
        model = models.SalesOrderShipment
1✔
1066
        fields = [
1✔
1067
            'order',
1068
        ]
1069

1070
    shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped')
1✔
1071

1072
    def filter_shipped(self, queryset, name, value):
1✔
1073
        """Filter SalesOrder list by 'shipped' status (boolean)"""
1074
        if str2bool(value):
×
1075
            return queryset.exclude(shipment_date=None)
×
1076
        else:
1077
            return queryset.filter(shipment_date=None)
×
1078

1079

1080
class SalesOrderShipmentList(ListCreateAPI):
1✔
1081
    """API list endpoint for SalesOrderShipment model."""
1082

1083
    queryset = models.SalesOrderShipment.objects.all()
1✔
1084
    serializer_class = serializers.SalesOrderShipmentSerializer
1✔
1085
    filterset_class = SalesOrderShipmentFilter
1✔
1086

1087
    filter_backends = [
1✔
1088
        rest_filters.DjangoFilterBackend,
1089
    ]
1090

1091

1092
class SalesOrderShipmentDetail(RetrieveUpdateDestroyAPI):
1✔
1093
    """API detail endpooint for SalesOrderShipment model."""
1094

1095
    queryset = models.SalesOrderShipment.objects.all()
1✔
1096
    serializer_class = serializers.SalesOrderShipmentSerializer
1✔
1097

1098

1099
class SalesOrderShipmentComplete(CreateAPI):
1✔
1100
    """API endpoint for completing (shipping) a SalesOrderShipment."""
1101

1102
    queryset = models.SalesOrderShipment.objects.all()
1✔
1103
    serializer_class = serializers.SalesOrderShipmentCompleteSerializer
1✔
1104

1105
    def get_serializer_context(self):
1✔
1106
        """Pass the request object to the serializer."""
1107
        ctx = super().get_serializer_context()
1✔
1108
        ctx['request'] = self.request
1✔
1109

1110
        try:
1✔
1111
            ctx['shipment'] = models.SalesOrderShipment.objects.get(
1✔
1112
                pk=self.kwargs.get('pk', None)
1113
            )
1114
        except Exception:
1✔
1115
            pass
1✔
1116

1117
        return ctx
1✔
1118

1119

1120
class SalesOrderShipmentMetadata(RetrieveUpdateAPI):
1✔
1121
    """API endpoint for viewing / updating SalesOrderShipment metadata."""
1122

1123
    def get_serializer(self, *args, **kwargs):
1✔
1124
        """Return MetadataSerializer instance"""
1125
        return MetadataSerializer(models.SalesOrderShipment, *args, **kwargs)
1✔
1126

1127
    queryset = models.SalesOrderShipment.objects.all()
1✔
1128

1129

1130
class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
1✔
1131
    """API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
1132

1133
    queryset = models.PurchaseOrderAttachment.objects.all()
1✔
1134
    serializer_class = serializers.PurchaseOrderAttachmentSerializer
1✔
1135

1136
    filter_backends = [
1✔
1137
        rest_filters.DjangoFilterBackend,
1138
    ]
1139

1140
    filterset_fields = [
1✔
1141
        'order',
1142
    ]
1143

1144

1145
class PurchaseOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
1✔
1146
    """Detail endpoint for a PurchaseOrderAttachment."""
1147

1148
    queryset = models.PurchaseOrderAttachment.objects.all()
1✔
1149
    serializer_class = serializers.PurchaseOrderAttachmentSerializer
1✔
1150

1151

1152
class ReturnOrderFilter(OrderFilter):
1✔
1153
    """Custom API filters for the ReturnOrderList endpoint"""
1154

1155
    class Meta:
1✔
1156
        """Metaclass options"""
1157

1158
        model = models.ReturnOrder
1✔
1159
        fields = [
1✔
1160
            'customer',
1161
        ]
1162

1163

1164
class ReturnOrderMixin:
1✔
1165
    """Mixin class for ReturnOrder endpoints"""
1166

1167
    queryset = models.ReturnOrder.objects.all()
1✔
1168
    serializer_class = serializers.ReturnOrderSerializer
1✔
1169

1170
    def get_serializer(self, *args, **kwargs):
1✔
1171
        """Return serializer instance for this endpoint"""
1172
        try:
1✔
1173
            kwargs['customer_detail'] = str2bool(
1✔
1174
                self.request.query_params.get('customer_detail', False)
1175
            )
1176
        except AttributeError:
1✔
1177
            pass
1✔
1178

1179
        # Ensure the context is passed through to the serializer
1180
        kwargs['context'] = self.get_serializer_context()
1✔
1181

1182
        return self.serializer_class(*args, **kwargs)
1✔
1183

1184
    def get_queryset(self, *args, **kwargs):
1✔
1185
        """Return annotated queryset for this endpoint"""
1186
        queryset = super().get_queryset(*args, **kwargs)
1✔
1187

1188
        queryset = queryset.prefetch_related(
1✔
1189
            'customer',
1190
        )
1191

1192
        queryset = serializers.ReturnOrderSerializer.annotate_queryset(queryset)
1✔
1193

1194
        return queryset
1✔
1195

1196

1197
class ReturnOrderList(ReturnOrderMixin, APIDownloadMixin, ListCreateAPI):
1✔
1198
    """API endpoint for accessing a list of ReturnOrder objects"""
1199

1200
    filterset_class = ReturnOrderFilter
1✔
1201

1202
    def create(self, request, *args, **kwargs):
1✔
1203
        """Save user information on create."""
1204
        serializer = self.get_serializer(data=self.clean_data(request.data))
1✔
1205
        serializer.is_valid(raise_exception=True)
1✔
1206

1207
        item = serializer.save()
1✔
1208
        item.created_by = request.user
1✔
1209
        item.save()
1✔
1210

1211
        headers = self.get_success_headers(serializer.data)
1✔
1212
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
1✔
1213

1214
    def download_queryset(self, queryset, export_format):
1✔
1215
        """Download this queryset as a file"""
1216

1217
        dataset = ReturnOrderResource().export(queryset=queryset)
×
1218
        filedata = dataset.export(export_format)
×
1219
        filename = f"InvenTree_ReturnOrders.{export_format}"
×
1220

1221
        return DownloadFile(filedata, filename)
×
1222

1223
    filter_backends = [
1✔
1224
        rest_filters.DjangoFilterBackend,
1225
        filters.SearchFilter,
1226
        InvenTreeOrderingFilter,
1227
    ]
1228

1229
    ordering_field_aliases = {
1✔
1230
        'reference': ['reference_int', 'reference'],
1231
    }
1232

1233
    ordering_fields = [
1✔
1234
        'creation_date',
1235
        'reference',
1236
        'customer__name',
1237
        'customer_reference',
1238
        'line_items',
1239
        'status',
1240
        'target_date',
1241
    ]
1242

1243
    search_fields = [
1✔
1244
        'customer__name',
1245
        'reference',
1246
        'description',
1247
        'customer_reference',
1248
    ]
1249

1250
    ordering = '-reference'
1✔
1251

1252

1253
class ReturnOrderDetail(ReturnOrderMixin, RetrieveUpdateDestroyAPI):
1✔
1254
    """API endpoint for detail view of a single ReturnOrder object"""
1255
    pass
1✔
1256

1257

1258
class ReturnOrderContextMixin:
1✔
1259
    """Simple mixin class to add a ReturnOrder to the serializer context"""
1260

1261
    queryset = models.ReturnOrder.objects.all()
1✔
1262

1263
    def get_serializer_context(self):
1✔
1264
        """Add the PurchaseOrder object to the serializer context."""
1265
        context = super().get_serializer_context()
1✔
1266

1267
        # Pass the ReturnOrder instance through to the serializer for validation
1268
        try:
1✔
1269
            context['order'] = models.ReturnOrder.objects.get(pk=self.kwargs.get('pk', None))
1✔
1270
        except Exception:
1✔
1271
            pass
1✔
1272

1273
        context['request'] = self.request
1✔
1274

1275
        return context
1✔
1276

1277

1278
class ReturnOrderCancel(ReturnOrderContextMixin, CreateAPI):
1✔
1279
    """API endpoint to cancel a ReturnOrder"""
1280
    serializer_class = serializers.ReturnOrderCancelSerializer
1✔
1281

1282

1283
class ReturnOrderComplete(ReturnOrderContextMixin, CreateAPI):
1✔
1284
    """API endpoint to complete a ReturnOrder"""
1285
    serializer_class = serializers.ReturnOrderCompleteSerializer
1✔
1286

1287

1288
class ReturnOrderIssue(ReturnOrderContextMixin, CreateAPI):
1✔
1289
    """API endpoint to issue (place) a ReturnOrder"""
1290
    serializer_class = serializers.ReturnOrderIssueSerializer
1✔
1291

1292

1293
class ReturnOrderReceive(ReturnOrderContextMixin, CreateAPI):
1✔
1294
    """API endpoint to receive items against a ReturnOrder"""
1295

1296
    queryset = models.ReturnOrder.objects.none()
1✔
1297
    serializer_class = serializers.ReturnOrderReceiveSerializer
1✔
1298

1299

1300
class ReturnOrderLineItemFilter(LineItemFilter):
1✔
1301
    """Custom filters for the ReturnOrderLineItemList endpoint"""
1302

1303
    class Meta:
1✔
1304
        """Metaclass options"""
1305
        price_field = 'price'
1✔
1306
        model = models.ReturnOrderLineItem
1✔
1307
        fields = [
1✔
1308
            'order',
1309
            'item',
1310
        ]
1311

1312
    outcome = rest_filters.NumberFilter(label='outcome')
1✔
1313

1314
    received = rest_filters.BooleanFilter(label='received', method='filter_received')
1✔
1315

1316
    def filter_received(self, queryset, name, value):
1✔
1317
        """Filter by 'received' field"""
1318

1319
        if str2bool(value):
×
1320
            return queryset.exclude(received_date=None)
×
1321
        else:
1322
            return queryset.filter(received_date=None)
×
1323

1324

1325
class ReturnOrderLineItemMixin:
1✔
1326
    """Mixin class for ReturnOrderLineItem endpoints"""
1327

1328
    queryset = models.ReturnOrderLineItem.objects.all()
1✔
1329
    serializer_class = serializers.ReturnOrderLineItemSerializer
1✔
1330

1331
    def get_serializer(self, *args, **kwargs):
1✔
1332
        """Return serializer for this endpoint with extra data as requested"""
1333

1334
        try:
1✔
1335
            params = self.request.query_params
1✔
1336

1337
            kwargs['order_detail'] = str2bool(params.get('order_detail', False))
×
1338
            kwargs['item_detail'] = str2bool(params.get('item_detail', True))
×
1339
            kwargs['part_detail'] = str2bool(params.get('part_detail', False))
×
1340
        except AttributeError:
1✔
1341
            pass
1✔
1342

1343
        kwargs['context'] = self.get_serializer_context()
1✔
1344

1345
        return self.serializer_class(*args, **kwargs)
1✔
1346

1347
    def get_queryset(self, *args, **kwargs):
1✔
1348
        """Return annotated queryset for this endpoint"""
1349

1350
        queryset = super().get_queryset(*args, **kwargs)
1✔
1351

1352
        queryset = queryset.prefetch_related(
1✔
1353
            'order',
1354
            'item',
1355
            'item__part',
1356
        )
1357

1358
        return queryset
1✔
1359

1360

1361
class ReturnOrderLineItemList(ReturnOrderLineItemMixin, APIDownloadMixin, ListCreateAPI):
1✔
1362
    """API endpoint for accessing a list of ReturnOrderLineItemList objects"""
1363

1364
    filterset_class = ReturnOrderLineItemFilter
1✔
1365

1366
    def download_queryset(self, queryset, export_format):
1✔
1367
        """Download the requested queryset as a file"""
1368

1369
        raise NotImplementedError("download_queryset not yet implemented for this endpoint")
×
1370

1371
    filter_backends = [
1✔
1372
        rest_filters.DjangoFilterBackend,
1373
        filters.SearchFilter,
1374
        filters.OrderingFilter,
1375
    ]
1376

1377
    ordering_fields = [
1✔
1378
        'reference',
1379
        'target_date',
1380
        'received_date',
1381
    ]
1382

1383
    search_fields = [
1✔
1384
        'item_serial',
1385
        'item__part__name',
1386
        'item__part__description',
1387
        'reference',
1388
    ]
1389

1390

1391
class ReturnOrderLineItemDetail(ReturnOrderLineItemMixin, RetrieveUpdateDestroyAPI):
1✔
1392
    """API endpoint for detail view of a ReturnOrderLineItem object"""
1393
    pass
1✔
1394

1395

1396
class ReturnOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
1✔
1397
    """API endpoint for accessing a list of ReturnOrderExtraLine objects"""
1398

1399
    queryset = models.ReturnOrderExtraLine.objects.all()
1✔
1400
    serializer_class = serializers.ReturnOrderExtraLineSerializer
1✔
1401

1402
    def download_queryset(self, queryset, export_format):
1✔
1403
        """Download this queryset as a file"""
1404

1405
        raise NotImplementedError("download_queryset not yet implemented")
×
1406

1407

1408
class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
1✔
1409
    """API endpoint for detail view of a ReturnOrderExtraLine object"""
1410

1411
    queryset = models.ReturnOrderExtraLine.objects.all()
1✔
1412
    serializer_class = serializers.ReturnOrderExtraLineSerializer
1✔
1413

1414

1415
class ReturnOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
1✔
1416
    """API endpoint for listing (and creating) a ReturnOrderAttachment (file upload)"""
1417

1418
    queryset = models.ReturnOrderAttachment.objects.all()
1✔
1419
    serializer_class = serializers.ReturnOrderAttachmentSerializer
1✔
1420

1421
    filter_backends = [
1✔
1422
        rest_filters.DjangoFilterBackend,
1423
    ]
1424

1425
    filterset_fields = [
1✔
1426
        'order',
1427
    ]
1428

1429

1430
class ReturnOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
1✔
1431
    """Detail endpoint for the ReturnOrderAttachment model"""
1432

1433
    queryset = models.ReturnOrderAttachment.objects.all()
1✔
1434
    serializer_class = serializers.ReturnOrderAttachmentSerializer
1✔
1435

1436

1437
class OrderCalendarExport(ICalFeed):
1✔
1438
    """Calendar export for Purchase/Sales Orders
1439

1440
    Optional parameters:
1441
    - include_completed: true/false
1442
        whether or not to show completed orders. Defaults to false
1443
    """
1444

1445
    try:
1✔
1446
        instance_url = InvenTreeSetting.get_setting('INVENTREE_BASE_URL', create=False, cache=False)
1✔
1447
    except ProgrammingError:  # pragma: no cover
1448
        # database is not initialized yet
1449
        instance_url = ''
1450
    instance_url = instance_url.replace("http://", "").replace("https://", "")
1✔
1451
    timezone = settings.TIME_ZONE
1✔
1452
    file_name = "calendar.ics"
1✔
1453

1454
    def __call__(self, request, *args, **kwargs):
1✔
1455
        """Overload call in order to check for authentication.
1456

1457
        This is required to force Django to look for the authentication,
1458
        otherwise login request with Basic auth via curl or similar are ignored,
1459
        and login via a calendar client will not work.
1460

1461
        See:
1462
        https://stackoverflow.com/questions/3817694/django-rss-feed-authentication
1463
        https://stackoverflow.com/questions/152248/can-i-use-http-basic-authentication-with-django
1464
        https://www.djangosnippets.org/snippets/243/
1465
        """
1466

1467
        import base64
1✔
1468

1469
        if request.user.is_authenticated:
1✔
1470
            # Authenticated on first try - maybe normal browser call?
1471
            return super().__call__(request, *args, **kwargs)
1✔
1472

1473
        # No login yet - check in headers
1474
        if 'HTTP_AUTHORIZATION' in request.META:
1✔
1475
            auth = request.META['HTTP_AUTHORIZATION'].split()
1✔
1476
            if len(auth) == 2:
1✔
1477
                # NOTE: We are only support basic authentication for now.
1478
                #
1479
                if auth[0].lower() == "basic":
1✔
1480
                    uname, passwd = base64.b64decode(auth[1]).decode("ascii").split(':')
1✔
1481
                    user = authenticate(username=uname, password=passwd)
1✔
1482
                    if user is not None:
1✔
1483
                        if user.is_active:
1✔
1484
                            login(request, user)
1✔
1485
                            request.user = user
1✔
1486

1487
        # Check again
1488
        if request.user.is_authenticated:
1✔
1489
            # Authenticated after second try
1490
            return super().__call__(request, *args, **kwargs)
1✔
1491

1492
        # Still nothing - return Unauth. header with info on how to authenticate
1493
        # Information is needed by client, eg Thunderbird
1494
        response = JsonResponse({"detail": "Authentication credentials were not provided."})
1✔
1495
        response['WWW-Authenticate'] = 'Basic realm="api"'
1✔
1496
        response.status_code = 401
1✔
1497
        return response
1✔
1498

1499
    def get_object(self, request, *args, **kwargs):
1✔
1500
        """This is where settings from the URL etc will be obtained"""
1501
        # Help:
1502
        # https://django.readthedocs.io/en/stable/ref/contrib/syndication.html
1503

1504
        obj = dict()
1✔
1505
        obj['ordertype'] = kwargs['ordertype']
1✔
1506
        obj['include_completed'] = bool(request.GET.get('include_completed', False))
1✔
1507

1508
        return obj
1✔
1509

1510
    def title(self, obj):
1✔
1511
        """Return calendar title."""
1512

1513
        if obj["ordertype"] == 'purchase-order':
1✔
1514
            ordertype_title = _('Purchase Order')
1✔
1515
        elif obj["ordertype"] == 'sales-order':
1✔
1516
            ordertype_title = _('Sales Order')
1✔
1517
        else:
1518
            ordertype_title = _('Unknown')
×
1519

1520
        return f'{InvenTreeSetting.get_setting("INVENTREE_COMPANY_NAME")} {ordertype_title}'
1✔
1521

1522
    def product_id(self, obj):
1✔
1523
        """Return calendar product id."""
1524
        return f'//{self.instance_url}//{self.title(obj)}//EN'
1✔
1525

1526
    def items(self, obj):
1✔
1527
        """Return a list of PurchaseOrders.
1528

1529
        Filters:
1530
        - Only return those which have a target_date set
1531
        - Only return incomplete orders, unless include_completed is set to True
1532
        """
1533
        if obj['ordertype'] == 'purchase-order':
1✔
1534
            if obj['include_completed'] is False:
1✔
1535
                # Do not include completed orders from list in this case
1536
                # Completed status = 30
1537
                outlist = models.PurchaseOrder.objects.filter(target_date__isnull=False).filter(status__lt=PurchaseOrderStatus.COMPLETE)
1✔
1538
            else:
1539
                outlist = models.PurchaseOrder.objects.filter(target_date__isnull=False)
1✔
1540
        else:
1541
            if obj['include_completed'] is False:
1✔
1542
                # Do not include completed (=shipped) orders from list in this case
1543
                # Shipped status = 20
1544
                outlist = models.SalesOrder.objects.filter(target_date__isnull=False).filter(status__lt=SalesOrderStatus.SHIPPED)
1✔
1545
            else:
1546
                outlist = models.SalesOrder.objects.filter(target_date__isnull=False)
1✔
1547

1548
        return outlist
1✔
1549

1550
    def item_title(self, item):
1✔
1551
        """Set the event title to the purchase order reference"""
1552
        return item.reference
1✔
1553

1554
    def item_description(self, item):
1✔
1555
        """Set the event description"""
1556
        return item.description
1✔
1557

1558
    def item_start_datetime(self, item):
1✔
1559
        """Set event start to target date. Goal is all-day event."""
1560
        return item.target_date
1✔
1561

1562
    def item_end_datetime(self, item):
1✔
1563
        """Set event end to target date. Goal is all-day event."""
1564
        return item.target_date
1✔
1565

1566
    def item_created(self, item):
1✔
1567
        """Use creation date of PO as creation date of event."""
1568
        return item.creation_date
1✔
1569

1570
    def item_class(self, item):
1✔
1571
        """Set item class to PUBLIC"""
1572
        return 'PUBLIC'
×
1573

1574
    def item_guid(self, item):
1✔
1575
        """Return globally unique UID for event"""
1576
        return f'po_{item.pk}_{item.reference.replace(" ","-")}@{self.instance_url}'
1✔
1577

1578
    def item_link(self, item):
1✔
1579
        """Set the item link."""
1580

1581
        # Do not use instance_url as here, as the protocol needs to be included
1582
        site_url = InvenTreeSetting.get_setting("INVENTREE_BASE_URL")
1✔
1583
        return f'{site_url}{item.get_absolute_url()}'
1✔
1584

1585

1586
order_api_urls = [
1✔
1587

1588
    # API endpoints for purchase orders
1589
    re_path(r'^po/', include([
1590

1591
        # Purchase order attachments
1592
        re_path(r'attachment/', include([
1593
            path('<int:pk>/', PurchaseOrderAttachmentDetail.as_view(), name='api-po-attachment-detail'),
1594
            re_path(r'^.*$', PurchaseOrderAttachmentList.as_view(), name='api-po-attachment-list'),
1595
        ])),
1596

1597
        # Individual purchase order detail URLs
1598
        path(r'<int:pk>/', include([
1599
            re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
1600
            re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
1601
            re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
1602
            re_path(r'^metadata/', PurchaseOrderMetadata.as_view(), name='api-po-metadata'),
1603
            re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
1604

1605
            # PurchaseOrder detail API endpoint
1606
            re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
1607
        ])),
1608

1609
        # Purchase order list
1610
        re_path(r'^.*$', PurchaseOrderList.as_view(), name='api-po-list'),
1611
    ])),
1612

1613
    # API endpoints for purchase order line items
1614
    re_path(r'^po-line/', include([
1615
        path('<int:pk>/', include([
1616
            re_path(r'^metadata/', PurchaseOrderLineItemMetadata.as_view(), name='api-po-line-metadata'),
1617
            re_path(r'^.*$', PurchaseOrderLineItemDetail.as_view(), name='api-po-line-detail'),
1618
        ])),
1619
        re_path(r'^.*$', PurchaseOrderLineItemList.as_view(), name='api-po-line-list'),
1620
    ])),
1621

1622
    # API endpoints for purchase order extra line
1623
    re_path(r'^po-extra-line/', include([
1624
        path('<int:pk>/', include([
1625
            re_path(r'^metadata/', PurchaseOrderExtraLineItemMetadata.as_view(), name='api-po-extra-line-metadata'),
1626
            re_path(r'^.*$', PurchaseOrderExtraLineDetail.as_view(), name='api-po-extra-line-detail'),
1627
        ])),
1628
        path('', PurchaseOrderExtraLineList.as_view(), name='api-po-extra-line-list'),
1629
    ])),
1630

1631
    # API endpoints for sales ordesr
1632
    re_path(r'^so/', include([
1633
        re_path(r'attachment/', include([
1634
            path('<int:pk>/', SalesOrderAttachmentDetail.as_view(), name='api-so-attachment-detail'),
1635
            re_path(r'^.*$', SalesOrderAttachmentList.as_view(), name='api-so-attachment-list'),
1636
        ])),
1637

1638
        re_path(r'^shipment/', include([
1639
            path(r'<int:pk>/', include([
1640
                path('ship/', SalesOrderShipmentComplete.as_view(), name='api-so-shipment-ship'),
1641
                re_path(r'^metadata/', SalesOrderShipmentMetadata.as_view(), name='api-so-shipment-metadata'),
1642
                re_path(r'^.*$', SalesOrderShipmentDetail.as_view(), name='api-so-shipment-detail'),
1643
            ])),
1644
            re_path(r'^.*$', SalesOrderShipmentList.as_view(), name='api-so-shipment-list'),
1645
        ])),
1646

1647
        # Sales order detail view
1648
        path(r'<int:pk>/', include([
1649
            re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
1650
            re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
1651
            re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
1652
            re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
1653
            re_path(r'^metadata/', SalesOrderMetadata.as_view(), name='api-so-metadata'),
1654

1655
            # SalesOrder detail endpoint
1656
            re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'),
1657
        ])),
1658

1659
        # Sales order list view
1660
        re_path(r'^.*$', SalesOrderList.as_view(), name='api-so-list'),
1661
    ])),
1662

1663
    # API endpoints for sales order line items
1664
    re_path(r'^so-line/', include([
1665
        path('<int:pk>/', include([
1666
            re_path(r'^metadata/', SalesOrderLineItemMetadata.as_view(), name='api-so-line-metadata'),
1667
            re_path(r'^.*$', SalesOrderLineItemDetail.as_view(), name='api-so-line-detail'),
1668
        ])),
1669
        path('', SalesOrderLineItemList.as_view(), name='api-so-line-list'),
1670
    ])),
1671

1672
    # API endpoints for sales order extra line
1673
    re_path(r'^so-extra-line/', include([
1674
        path('<int:pk>/', include([
1675
            re_path(r'^metadata/', SalesOrderExtraLineItemMetadata.as_view(), name='api-so-extra-line-metadata'),
1676
            re_path(r'^.*$', SalesOrderExtraLineDetail.as_view(), name='api-so-extra-line-detail'),
1677
        ])),
1678
        path('', SalesOrderExtraLineList.as_view(), name='api-so-extra-line-list'),
1679
    ])),
1680

1681
    # API endpoints for sales order allocations
1682
    re_path(r'^so-allocation/', include([
1683
        path('<int:pk>/', SalesOrderAllocationDetail.as_view(), name='api-so-allocation-detail'),
1684
        re_path(r'^.*$', SalesOrderAllocationList.as_view(), name='api-so-allocation-list'),
1685
    ])),
1686

1687
    # API endpoints for return orders
1688
    re_path(r'^ro/', include([
1689

1690
        re_path(r'^attachment/', include([
1691
            path('<int:pk>/', ReturnOrderAttachmentDetail.as_view(), name='api-return-order-attachment-detail'),
1692
            re_path(r'^.*$', ReturnOrderAttachmentList.as_view(), name='api-return-order-attachment-list'),
1693
        ])),
1694

1695
        # Return Order detail endpoints
1696
        path('<int:pk>/', include([
1697
            re_path(r'cancel/', ReturnOrderCancel.as_view(), name='api-return-order-cancel'),
1698
            re_path(r'complete/', ReturnOrderComplete.as_view(), name='api-return-order-complete'),
1699
            re_path(r'issue/', ReturnOrderIssue.as_view(), name='api-return-order-issue'),
1700
            re_path(r'receive/', ReturnOrderReceive.as_view(), name='api-return-order-receive'),
1701
            re_path(r'.*$', ReturnOrderDetail.as_view(), name='api-return-order-detail'),
1702
        ])),
1703

1704
        # Return Order list
1705
        re_path(r'^.*$', ReturnOrderList.as_view(), name='api-return-order-list'),
1706
    ])),
1707

1708
    # API endpoints for reutrn order lines
1709
    re_path(r'^ro-line/', include([
1710
        path('<int:pk>/', ReturnOrderLineItemDetail.as_view(), name='api-return-order-line-detail'),
1711
        path('', ReturnOrderLineItemList.as_view(), name='api-return-order-line-list'),
1712
    ])),
1713

1714
    # API endpoints for return order extra line
1715
    re_path(r'^ro-extra-line/', include([
1716
        path('<int:pk>/', ReturnOrderExtraLineDetail.as_view(), name='api-return-order-extra-line-detail'),
1717
        path('', ReturnOrderExtraLineList.as_view(), name='api-return-order-extra-line-list'),
1718
    ])),
1719

1720
    # API endpoint for subscribing to ICS calendar of purchase/sales orders
1721
    re_path(r'^calendar/(?P<ordertype>purchase-order|sales-order)/calendar.ics', OrderCalendarExport(), name='api-po-so-calendar'),
1722
]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc