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

1"""JSON API for the Order app.""" 

2 

3from decimal import Decimal 

4from typing import cast 

5 

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 _ 

14 

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 

24 

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 

67 

68 

69class GeneralExtraLineListOutputOptions(OutputConfiguration): 

70 """Output options for the GeneralExtraLineList endpoint.""" 

71 

72 OPTIONS = [InvenTreeOutputOption('order_detail')] 

73 

74 

75class GeneralExtraLineList(SerializerContextMixin, DataExportViewMixin): 

76 """General template for ExtraLine API classes.""" 

77 

78 def get_queryset(self, *args, **kwargs): 

79 """Return the annotated queryset for this endpoint.""" 

80 queryset = super().get_queryset(*args, **kwargs) 

81 

82 queryset = queryset.prefetch_related('order') 

83 

84 return queryset 

85 

86 output_options = GeneralExtraLineListOutputOptions 

87 

88 filter_backends = SEARCH_ORDER_FILTER 

89 

90 ordering_fields = ['quantity', 'notes', 'reference', 'line'] 

91 

92 ordering_field_aliases = {'line': ['line_int', 'line']} 

93 

94 search_fields = ['quantity', 'notes', 'reference', 'description'] 

95 

96 filterset_fields = ['order'] 

97 

98 

99class OrderCreateMixin: 

100 """Mixin class which handles order creation via API.""" 

101 

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) 

106 

107 serializer.save(created_by=request.user) 

108 

109 headers = self.get_success_headers(serializer.data) 

110 return Response( 

111 serializer.data, status=status.HTTP_201_CREATED, headers=headers 

112 ) 

113 

114 

115class OrderFilter(FilterSet): 

116 """Base class for custom API filters for the OrderList endpoint.""" 

117 

118 # Filter against order status 

119 status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status') 

120 

121 def filter_status(self, queryset, name, value): 

122 """Filter by integer status code. 

123 

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) 

128 

129 return queryset.filter(q1 | q2).distinct() 

130 

131 # Exact match for reference 

132 reference = rest_filters.CharFilter( 

133 label=_('Order Reference'), field_name='reference', lookup_expr='iexact' 

134 ) 

135 

136 assigned_to_me = rest_filters.BooleanFilter( 

137 label=_('Assigned to me'), method='filter_assigned_to_me' 

138 ) 

139 

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) 

144 

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) 

148 

149 overdue = rest_filters.BooleanFilter(label='overdue', method='filter_overdue') 

150 

151 def filter_overdue(self, queryset, name, value): 

152 """Generic filter for determining if an order is 'overdue'. 

153 

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()) 

159 

160 outstanding = rest_filters.BooleanFilter( 

161 label=_('Outstanding'), method='filter_outstanding' 

162 ) 

163 

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) 

169 

170 project_code = rest_filters.ModelChoiceFilter( 

171 queryset=common.models.ProjectCode.objects.all(), 

172 field_name='project_code', 

173 label=_('Project Code'), 

174 ) 

175 

176 has_project_code = rest_filters.BooleanFilter( 

177 method='filter_has_project_code', label=_('Has Project Code') 

178 ) 

179 

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) 

185 

186 assigned_to = rest_filters.ModelChoiceFilter( 

187 queryset=Owner.objects.all(), field_name='responsible', label=_('Responsible') 

188 ) 

189 

190 created_by = rest_filters.ModelChoiceFilter( 

191 queryset=User.objects.all(), field_name='created_by', label=_('Created By') 

192 ) 

193 

194 created_before = InvenTreeDateFilter( 

195 label=_('Created Before'), field_name='creation_date', lookup_expr='lt' 

196 ) 

197 

198 created_after = InvenTreeDateFilter( 

199 label=_('Created After'), field_name='creation_date', lookup_expr='gt' 

200 ) 

201 

202 has_start_date = rest_filters.BooleanFilter( 

203 label=_('Has Start Date'), method='filter_has_start_date' 

204 ) 

205 

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)) 

209 

210 start_date_before = InvenTreeDateFilter( 

211 label=_('Start Date Before'), field_name='start_date', lookup_expr='lt' 

212 ) 

213 

214 start_date_after = InvenTreeDateFilter( 

215 label=_('Start Date After'), field_name='start_date', lookup_expr='gt' 

216 ) 

217 

218 has_target_date = rest_filters.BooleanFilter( 

219 label=_('Has Target Date'), method='filter_has_target_date' 

220 ) 

221 

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)) 

225 

226 target_date_before = InvenTreeDateFilter( 

227 label=_('Target Date Before'), field_name='target_date', lookup_expr='lt' 

228 ) 

229 

230 target_date_after = InvenTreeDateFilter( 

231 label=_('Target Date After'), field_name='target_date', lookup_expr='gt' 

232 ) 

233 

234 updated_before = InvenTreeDateFilter( 

235 label=_('Updated Before'), field_name='updated_at', lookup_expr='lt' 

236 ) 

237 

238 updated_after = InvenTreeDateFilter( 

239 label=_('Updated After'), field_name='updated_at', lookup_expr='gt' 

240 ) 

241 

242 min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date') 

243 

244 def filter_min_date(self, queryset, name, value): 

245 """Filter the queryset to include orders *after* a specified date. 

246 

247 This is used in combination with filter_max_date, 

248 to provide a queryset which matches a particular range of dates. 

249 

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) 

258 

259 return queryset.filter(q1 | q2 | q3 | q4).distinct() 

260 

261 max_date = InvenTreeDateFilter(label=_('Max Date'), method='filter_max_date') 

262 

263 def filter_max_date(self, queryset, name, value): 

264 """Filter the queryset to include orders *before* a specified date. 

265 

266 This is used in combination with filter_min_date, 

267 to provide a queryset which matches a particular range of dates. 

268 

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) 

277 

278 return queryset.filter(q1 | q2 | q3 | q4).distinct() 

279 

280 tags = common.filters.TagsFilter() 

281 

282 

283class LineItemFilter(FilterSet): 

284 """Base class for custom API filters for order line item list(s).""" 

285 

286 # Filter by order status 

287 order_status = rest_filters.NumberFilter( 

288 label=_('Order Status'), field_name='order__status' 

289 ) 

290 

291 has_pricing = rest_filters.BooleanFilter( 

292 label=_('Has Pricing'), method='filter_has_pricing' 

293 ) 

294 

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} 

298 

299 if str2bool(value): 

300 return queryset.exclude(**filters) 

301 return queryset.filter(**filters) 

302 

303 

304class PurchaseOrderFilter(OrderFilter): 

305 """Custom API filters for the PurchaseOrderList endpoint.""" 

306 

307 class Meta: 

308 """Metaclass options.""" 

309 

310 model = models.PurchaseOrder 

311 fields = ['supplier'] 

312 

313 part = rest_filters.ModelChoiceFilter( 

314 queryset=Part.objects.all(), 

315 field_name='part', 

316 label=_('Part'), 

317 method='filter_part', 

318 ) 

319 

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() 

324 

325 return queryset.filter(pk__in=[o.pk for o in orders]) 

326 

327 supplier_part = rest_filters.ModelChoiceFilter( 

328 queryset=company.models.SupplierPart.objects.all(), 

329 label=_('Supplier Part'), 

330 method='filter_supplier_part', 

331 ) 

332 

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() 

341 

342 return queryset.filter(pk__in=[o.pk for o in orders]) 

343 

344 completed_before = InvenTreeDateFilter( 

345 label=_('Completed Before'), field_name='complete_date', lookup_expr='lt' 

346 ) 

347 

348 completed_after = InvenTreeDateFilter( 

349 label=_('Completed After'), field_name='complete_date', lookup_expr='gt' 

350 ) 

351 

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 ) 

357 

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. 

363 

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() 

367 

368 

369class PurchaseOrderOutputOptions(OutputConfiguration): 

370 """Output options for the PurchaseOrder endpoint.""" 

371 

372 OPTIONS = [InvenTreeOutputOption('supplier_detail')] 

373 

374 

375class PurchaseOrderMixin(SerializerContextMixin): 

376 """Mixin class for PurchaseOrder endpoints.""" 

377 

378 queryset = models.PurchaseOrder.objects.all().prefetch_related( 

379 'supplier', 'created_by' 

380 ) 

381 serializer_class = serializers.PurchaseOrderSerializer 

382 

383 def get_queryset(self, *args, **kwargs): 

384 """Return the annotated queryset for this endpoint.""" 

385 queryset = super().get_queryset(*args, **kwargs) 

386 

387 queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) 

388 

389 return queryset 

390 

391 

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. 

401 

402 - GET: Return list of PurchaseOrder objects (with filters) 

403 - POST: Create a new PurchaseOrder object 

404 """ 

405 

406 filterset_class = PurchaseOrderFilter 

407 filter_backends = SEARCH_ORDER_FILTER 

408 output_options = PurchaseOrderOutputOptions 

409 

410 ordering_field_aliases = { 

411 'reference': ['reference_int', 'reference'], 

412 'project_code': ['project_code__code'], 

413 } 

414 

415 search_fields = [ 

416 'reference', 

417 'supplier__name', 

418 'supplier_reference', 

419 'project_code__code', 

420 'description', 

421 ] 

422 

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 ] 

438 

439 ordering = '-reference' 

440 

441 

442class PurchaseOrderDetail( 

443 PurchaseOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI 

444): 

445 """API endpoint for detail view of a PurchaseOrder object.""" 

446 

447 output_options = PurchaseOrderOutputOptions 

448 

449 

450class PurchaseOrderContextMixin: 

451 """Mixin to add purchase order object as serializer context variable.""" 

452 

453 queryset = models.PurchaseOrder.objects.all() 

454 

455 def get_serializer_context(self): 

456 """Add the PurchaseOrder object to the serializer context.""" 

457 context = super().get_serializer_context() 

458 

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 

466 

467 context['request'] = self.request 

468 

469 return context 

470 

471 

472class PurchaseOrderHold(PurchaseOrderContextMixin, CreateAPI): 

473 """API endpoint to place a PurchaseOrder on hold.""" 

474 

475 serializer_class = serializers.PurchaseOrderHoldSerializer 

476 

477 

478class PurchaseOrderCancel(PurchaseOrderContextMixin, CreateAPI): 

479 """API endpoint to 'cancel' a purchase order. 

480 

481 The purchase order must be in a state which can be cancelled 

482 """ 

483 

484 serializer_class = serializers.PurchaseOrderCancelSerializer 

485 

486 

487class PurchaseOrderComplete(PurchaseOrderContextMixin, CreateAPI): 

488 """API endpoint to 'complete' a purchase order.""" 

489 

490 serializer_class = serializers.PurchaseOrderCompleteSerializer 

491 

492 

493class PurchaseOrderIssue(PurchaseOrderContextMixin, CreateAPI): 

494 """API endpoint to 'issue' (place) a PurchaseOrder.""" 

495 

496 serializer_class = serializers.PurchaseOrderIssueSerializer 

497 

498 

499@extend_schema(responses={201: stock_serializers.StockItemSerializer(many=True)}) 

500class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI): 

501 """API endpoint to receive stock items against a PurchaseOrder. 

502 

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 """ 

515 

516 queryset = models.PurchaseOrderLineItem.objects.none() 

517 serializer_class = serializers.PurchaseOrderReceiveSerializer 

518 pagination_class = None 

519 

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) 

527 

528 return Response(response.data, status=status.HTTP_201_CREATED) 

529 

530 

531class PurchaseOrderLineItemFilter(LineItemFilter): 

532 """Custom filters for the PurchaseOrderLineItemList endpoint.""" 

533 

534 class Meta: 

535 """Metaclass options.""" 

536 

537 price_field = 'purchase_price' 

538 model = models.PurchaseOrderLineItem 

539 fields = [] 

540 

541 order = rest_filters.ModelChoiceFilter( 

542 queryset=models.PurchaseOrder.objects.all(), 

543 field_name='order', 

544 label=_('Order'), 

545 ) 

546 

547 order_complete = rest_filters.BooleanFilter( 

548 label=_('Order Complete'), method='filter_order_complete' 

549 ) 

550 

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) 

555 

556 return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value) 

557 

558 part = rest_filters.ModelChoiceFilter( 

559 queryset=company.models.SupplierPart.objects.all(), 

560 field_name='part', 

561 label=_('Supplier Part'), 

562 ) 

563 

564 include_variants = rest_filters.BooleanFilter( 

565 label=_('Include Variants'), method='filter_include_variants' 

566 ) 

567 

568 def filter_include_variants(self, queryset, name, value): 

569 """Filter by whether or not to include variants of the selected part. 

570 

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 

576 

577 base_part = rest_filters.ModelChoiceFilter( 

578 queryset=Part.objects.filter(purchaseable=True), 

579 method='filter_base_part', 

580 label=_('Internal Part'), 

581 ) 

582 

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. 

588 

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)) 

594 

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) 

600 

601 pending = rest_filters.BooleanFilter( 

602 method='filter_pending', label=_('Order Pending') 

603 ) 

604 

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) 

610 

611 received = rest_filters.BooleanFilter( 

612 label=_('Items Received'), method='filter_received' 

613 ) 

614 

615 def filter_received(self, queryset, name, value): 

616 """Filter by lines which are "received" (or "not" received). 

617 

618 A line is considered "received" when received >= quantity 

619 """ 

620 q = Q(received__gte=F('quantity')) 

621 

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 ) 

628 

629 

630class PurchaseOrderLineItemOutputOptions(OutputConfiguration): 

631 """Output options for the PurchaseOrderLineItem endpoint.""" 

632 

633 OPTIONS = [ 

634 InvenTreeOutputOption('part_detail'), 

635 InvenTreeOutputOption('order_detail'), 

636 ] 

637 

638 

639class PurchaseOrderLineItemMixin(SerializerContextMixin): 

640 """Mixin class for PurchaseOrderLineItem endpoints.""" 

641 

642 queryset = models.PurchaseOrderLineItem.objects.all() 

643 serializer_class = serializers.PurchaseOrderLineItemSerializer 

644 

645 def get_queryset(self, *args, **kwargs): 

646 """Return annotated queryset for this endpoint.""" 

647 queryset = super().get_queryset(*args, **kwargs) 

648 

649 queryset = serializers.PurchaseOrderLineItemSerializer.annotate_queryset( 

650 queryset 

651 ) 

652 

653 return queryset 

654 

655 def perform_update(self, serializer): 

656 """Override the perform_update method to auto-update pricing if required.""" 

657 super().perform_update(serializer) 

658 

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() 

662 

663 

664class PurchaseOrderLineItemList( 

665 PurchaseOrderLineItemMixin, 

666 DataExportViewMixin, 

667 OutputOptionsMixin, 

668 ListCreateDestroyAPIView, 

669): 

670 """API endpoint for accessing a list of PurchaseOrderLineItem objects. 

671 

672 - GET: Return a list of PurchaseOrder Line Item objects 

673 - POST: Create a new PurchaseOrderLineItem object 

674 """ 

675 

676 filterset_class = PurchaseOrderLineItemFilter 

677 output_options = PurchaseOrderLineItemOutputOptions 

678 

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) 

684 

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 ) 

702 

703 if other_line is not None: 

704 other_line.quantity += Decimal(data.get('quantity', 0)) 

705 other_line.save() 

706 

707 line_item = other_line 

708 

709 # otherwise create a new line item 

710 if line_item is None: 

711 line_item = serializer.save() 

712 

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() 

718 

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 ) 

724 

725 filter_backends = SEARCH_ORDER_FILTER 

726 

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 } 

737 

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 ] 

754 

755 search_fields = [ 

756 'part__part__name', 

757 'part__part__description', 

758 'part__manufacturer_part__MPN', 

759 'part__SKU', 

760 'reference', 

761 ] 

762 

763 

764class PurchaseOrderLineItemDetail( 

765 PurchaseOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI 

766): 

767 """Detail API endpoint for PurchaseOrderLineItem object.""" 

768 

769 output_options = PurchaseOrderLineItemOutputOptions 

770 

771 

772class PurchaseOrderExtraLineList( 

773 GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI 

774): 

775 """API endpoint for accessing a list of PurchaseOrderExtraLine objects.""" 

776 

777 queryset = models.PurchaseOrderExtraLine.objects.all() 

778 serializer_class = serializers.PurchaseOrderExtraLineSerializer 

779 

780 

781class PurchaseOrderExtraLineDetail(RetrieveUpdateDestroyAPI): 

782 """API endpoint for detail view of a PurchaseOrderExtraLine object.""" 

783 

784 queryset = models.PurchaseOrderExtraLine.objects.all() 

785 serializer_class = serializers.PurchaseOrderExtraLineSerializer 

786 

787 

788class SalesOrderFilter(OrderFilter): 

789 """Custom API filters for the SalesOrderList endpoint.""" 

790 

791 class Meta: 

792 """Metaclass options.""" 

793 

794 model = models.SalesOrder 

795 fields = ['customer'] 

796 

797 include_variants = rest_filters.BooleanFilter( 

798 label=_('Include Variants'), method='filter_include_variants' 

799 ) 

800 

801 def filter_include_variants(self, queryset, name, value): 

802 """Filter by whether or not to include variants of the selected part. 

803 

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 

809 

810 part = rest_filters.ModelChoiceFilter( 

811 queryset=Part.objects.all(), field_name='part', method='filter_part' 

812 ) 

813 

814 @extend_schema_field(OpenApiTypes.INT) 

815 def filter_part(self, queryset, name, part): 

816 """Filter SalesOrder by selected 'part'. 

817 

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)) 

823 

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) 

829 

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) 

832 

833 # Generate a list of ID values for the matching sales orders 

834 sales_orders = line_items.values_list('order', flat=True).distinct() 

835 

836 # Now we have a list of matching IDs, filter the queryset 

837 return queryset.filter(pk__in=sales_orders) 

838 

839 completed_before = InvenTreeDateFilter( 

840 label=_('Completed Before'), field_name='shipment_date', lookup_expr='lt' 

841 ) 

842 

843 completed_after = InvenTreeDateFilter( 

844 label=_('Completed After'), field_name='shipment_date', lookup_expr='gt' 

845 ) 

846 

847 

848class SalesOrderMixin(SerializerContextMixin): 

849 """Mixin class for SalesOrder endpoints.""" 

850 

851 queryset = models.SalesOrder.objects.all().prefetch_related( 

852 'customer', 'created_by' 

853 ) 

854 serializer_class = serializers.SalesOrderSerializer 

855 

856 def get_queryset(self, *args, **kwargs): 

857 """Return annotated queryset for this endpoint.""" 

858 queryset = super().get_queryset(*args, **kwargs) 

859 

860 queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset) 

861 

862 return queryset 

863 

864 

865class SalesOrderOutputOptions(OutputConfiguration): 

866 """Output options for the SalesOrder endpoint.""" 

867 

868 OPTIONS = [InvenTreeOutputOption('customer_detail')] 

869 

870 

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. 

880 

881 - GET: Return list of SalesOrder objects (with filters) 

882 - POST: Create a new SalesOrder 

883 """ 

884 

885 filterset_class = SalesOrderFilter 

886 filter_backends = SEARCH_ORDER_FILTER 

887 output_options = SalesOrderOutputOptions 

888 

889 ordering_field_aliases = { 

890 'reference': ['reference_int', 'reference'], 

891 'project_code': ['project_code__code'], 

892 } 

893 

894 filterset_fields = ['customer'] 

895 

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 ] 

911 

912 search_fields = [ 

913 'customer__name', 

914 'reference', 

915 'description', 

916 'customer_reference', 

917 'project_code__code', 

918 ] 

919 

920 ordering = '-reference' 

921 

922 

923class SalesOrderDetail(SalesOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI): 

924 """API endpoint for detail view of a SalesOrder object.""" 

925 

926 output_options = SalesOrderOutputOptions 

927 

928 

929class SalesOrderLineItemFilter(LineItemFilter): 

930 """Custom filters for SalesOrderLineItemList endpoint.""" 

931 

932 class Meta: 

933 """Metaclass options.""" 

934 

935 price_field = 'sale_price' 

936 model = models.SalesOrderLineItem 

937 fields = [] 

938 

939 order = rest_filters.ModelChoiceFilter( 

940 queryset=models.SalesOrder.objects.all(), field_name='order', label=_('Order') 

941 ) 

942 

943 def filter_include_variants(self, queryset, name, value): 

944 """Filter by whether or not to include variants of the selected part. 

945 

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 

951 

952 part = rest_filters.ModelChoiceFilter( 

953 queryset=Part.objects.all(), 

954 field_name='part', 

955 label=_('Part'), 

956 method='filter_part', 

957 ) 

958 

959 @extend_schema_field(OpenApiTypes.INT) 

960 def filter_part(self, queryset, name, part): 

961 """Filter SalesOrderLineItem by selected 'part'. 

962 

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)) 

968 

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) 

974 

975 return queryset.filter(part__in=parts) 

976 

977 allocated = rest_filters.BooleanFilter( 

978 label=_('Allocated'), method='filter_allocated' 

979 ) 

980 

981 def filter_allocated(self, queryset, name, value): 

982 """Filter by lines which are 'allocated'. 

983 

984 A line is 'allocated' when allocated >= quantity 

985 """ 

986 q = Q(allocated__gte=F('quantity')) 

987 

988 if str2bool(value): 

989 return queryset.filter(q) 

990 return queryset.exclude(q) 

991 

992 completed = rest_filters.BooleanFilter( 

993 label=_('Completed'), method='filter_completed' 

994 ) 

995 

996 def filter_completed(self, queryset, name, value): 

997 """Filter by lines which are "completed". 

998 

999 A line is 'completed' when shipped >= quantity 

1000 """ 

1001 q = Q(shipped__gte=F('quantity')) 

1002 

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) 

1006 

1007 order_complete = rest_filters.BooleanFilter( 

1008 label=_('Order Complete'), method='filter_order_complete' 

1009 ) 

1010 

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) 

1015 

1016 return queryset.exclude(order__status__in=SalesOrderStatusGroups.COMPLETE) 

1017 

1018 order_outstanding = rest_filters.BooleanFilter( 

1019 label=_('Order Outstanding'), method='filter_order_outstanding' 

1020 ) 

1021 

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) 

1026 

1027 return queryset.exclude(order__status__in=SalesOrderStatusGroups.OPEN) 

1028 

1029 

1030class SalesOrderLineItemMixin(SerializerContextMixin): 

1031 """Mixin class for SalesOrderLineItem endpoints.""" 

1032 

1033 queryset = models.SalesOrderLineItem.objects.all() 

1034 serializer_class = serializers.SalesOrderLineItemSerializer 

1035 

1036 def get_queryset(self, *args, **kwargs): 

1037 """Return annotated queryset for this endpoint.""" 

1038 queryset = super().get_queryset(*args, **kwargs) 

1039 

1040 queryset = queryset.prefetch_related( 

1041 'part', 

1042 'allocations', 

1043 'allocations__shipment', 

1044 'allocations__item__part', 

1045 'allocations__item__location', 

1046 'order', 

1047 ) 

1048 

1049 queryset = serializers.SalesOrderLineItemSerializer.annotate_queryset(queryset) 

1050 

1051 return queryset 

1052 

1053 

1054class SalesOrderLineItemOutputOptions(OutputConfiguration): 

1055 """Output options for the SalesOrderAllocation endpoint.""" 

1056 

1057 OPTIONS = [ 

1058 InvenTreeOutputOption('part_detail'), 

1059 InvenTreeOutputOption('order_detail'), 

1060 InvenTreeOutputOption('customer_detail'), 

1061 ] 

1062 

1063 

1064class SalesOrderLineItemList( 

1065 SalesOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI 

1066): 

1067 """API endpoint for accessing a list of SalesOrderLineItem objects.""" 

1068 

1069 filterset_class = SalesOrderLineItemFilter 

1070 

1071 filter_backends = SEARCH_ORDER_FILTER 

1072 

1073 output_options = SalesOrderLineItemOutputOptions 

1074 

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 ] 

1089 

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 } 

1097 

1098 search_fields = ['part__name', 'quantity', 'reference'] 

1099 

1100 

1101class SalesOrderLineItemDetail( 

1102 SalesOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI 

1103): 

1104 """API endpoint for detail view of a SalesOrderLineItem object.""" 

1105 

1106 output_options = SalesOrderLineItemOutputOptions 

1107 

1108 

1109class SalesOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI): 

1110 """API endpoint for accessing a list of SalesOrderExtraLine objects.""" 

1111 

1112 queryset = models.SalesOrderExtraLine.objects.all() 

1113 serializer_class = serializers.SalesOrderExtraLineSerializer 

1114 

1115 

1116class SalesOrderExtraLineDetail(RetrieveUpdateDestroyAPI): 

1117 """API endpoint for detail view of a SalesOrderExtraLine object.""" 

1118 

1119 queryset = models.SalesOrderExtraLine.objects.all() 

1120 serializer_class = serializers.SalesOrderExtraLineSerializer 

1121 

1122 

1123class SalesOrderContextMixin: 

1124 """Mixin to add sales order object as serializer context variable.""" 

1125 

1126 queryset = models.SalesOrder.objects.all() 

1127 

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() 

1131 

1132 ctx['request'] = self.request 

1133 

1134 try: 

1135 ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None)) 

1136 except Exception: 

1137 pass 

1138 

1139 return ctx 

1140 

1141 

1142class SalesOrderHold(SalesOrderContextMixin, CreateAPI): 

1143 """API endpoint to place a SalesOrder on hold.""" 

1144 

1145 serializer_class = serializers.SalesOrderHoldSerializer 

1146 

1147 

1148class SalesOrderCancel(SalesOrderContextMixin, CreateAPI): 

1149 """API endpoint to cancel a SalesOrder.""" 

1150 

1151 serializer_class = serializers.SalesOrderCancelSerializer 

1152 

1153 

1154class SalesOrderIssue(SalesOrderContextMixin, CreateAPI): 

1155 """API endpoint to issue a SalesOrder.""" 

1156 

1157 serializer_class = serializers.SalesOrderIssueSerializer 

1158 

1159 

1160class SalesOrderComplete(SalesOrderContextMixin, CreateAPI): 

1161 """API endpoint for manually marking a SalesOrder as "complete".""" 

1162 

1163 serializer_class = serializers.SalesOrderCompleteSerializer 

1164 

1165 

1166class SalesOrderAllocateSerials(SalesOrderContextMixin, CreateAPI): 

1167 """API endpoint to allocation stock items against a SalesOrder, by specifying serial numbers.""" 

1168 

1169 queryset = models.SalesOrder.objects.none() 

1170 serializer_class = serializers.SalesOrderSerialAllocationSerializer 

1171 

1172 

1173class SalesOrderAllocate(SalesOrderContextMixin, CreateAPI): 

1174 """API endpoint to allocate stock items against a SalesOrder. 

1175 

1176 - The SalesOrder is specified in the URL 

1177 - See the SalesOrderShipmentAllocationSerializer class 

1178 """ 

1179 

1180 queryset = models.SalesOrder.objects.none() 

1181 serializer_class = serializers.SalesOrderShipmentAllocationSerializer 

1182 

1183 

1184class SalesOrderAutoAllocate(SalesOrderContextMixin, CreateAPI): 

1185 """API endpoint to automatically allocate stock against a SalesOrder. 

1186 

1187 - Offloads work to a background task and returns task detail 

1188 """ 

1189 

1190 serializer_class = serializers.SalesOrderAutoAllocationSerializer 

1191 

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 

1197 

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 

1202 

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', []) 

1208 

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 ) 

1223 

1224 response = common.serializers.TaskDetailSerializer.from_task(task_id).data 

1225 return Response(response, status=response['http_status']) 

1226 

1227 

1228class SalesOrderAllocationFilter(FilterSet): 

1229 """Custom filterset for the SalesOrderAllocationList endpoint.""" 

1230 

1231 class Meta: 

1232 """Metaclass options.""" 

1233 

1234 model = models.SalesOrderAllocation 

1235 fields = ['shipment', 'line', 'item'] 

1236 

1237 order = rest_filters.ModelChoiceFilter( 

1238 queryset=models.SalesOrder.objects.all(), 

1239 field_name='line__order', 

1240 label=_('Order'), 

1241 ) 

1242 

1243 include_variants = rest_filters.BooleanFilter( 

1244 label=_('Include Variants'), method='filter_include_variants' 

1245 ) 

1246 

1247 def filter_include_variants(self, queryset, name, value): 

1248 """Filter by whether or not to include variants of the selected part. 

1249 

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 

1255 

1256 part = rest_filters.ModelChoiceFilter( 

1257 queryset=Part.objects.all(), method='filter_part', label=_('Part') 

1258 ) 

1259 

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. 

1263 

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)) 

1269 

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) 

1275 

1276 outstanding = rest_filters.BooleanFilter( 

1277 label=_('Outstanding'), method='filter_outstanding' 

1278 ) 

1279 

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 ) 

1291 

1292 assigned_to_shipment = rest_filters.BooleanFilter( 

1293 label=_('Has Shipment'), method='filter_assigned_to_shipment' 

1294 ) 

1295 

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) 

1301 

1302 location = rest_filters.ModelChoiceFilter( 

1303 queryset=stock_models.StockLocation.objects.all(), 

1304 label=_('Location'), 

1305 method='filter_location', 

1306 ) 

1307 

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) 

1315 

1316 

1317class SalesOrderAllocationMixin: 

1318 """Mixin class for SalesOrderAllocation endpoints.""" 

1319 

1320 queryset = models.SalesOrderAllocation.objects.all() 

1321 serializer_class = serializers.SalesOrderAllocationSerializer 

1322 

1323 def get_queryset(self, *args, **kwargs): 

1324 """Annotate the queryset for this endpoint.""" 

1325 queryset = super().get_queryset(*args, **kwargs) 

1326 

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') 

1341 

1342 return queryset 

1343 

1344 

1345class SalesOrderAllocationOutputOptions(OutputConfiguration): 

1346 """Output options for the SalesOrderAllocation endpoint.""" 

1347 

1348 OPTIONS = [ 

1349 InvenTreeOutputOption('part_detail'), 

1350 InvenTreeOutputOption('item_detail'), 

1351 InvenTreeOutputOption('order_detail'), 

1352 InvenTreeOutputOption('location_detail'), 

1353 InvenTreeOutputOption('customer_detail'), 

1354 ] 

1355 

1356 

1357class SalesOrderAllocationList( 

1358 SalesOrderAllocationMixin, 

1359 BulkDeleteMixin, 

1360 BulkUpdateMixin, 

1361 OutputOptionsMixin, 

1362 ListAPI, 

1363): 

1364 """API endpoint for listing SalesOrderAllocation objects.""" 

1365 

1366 filterset_class = SalesOrderAllocationFilter 

1367 filter_backends = SEARCH_ORDER_FILTER 

1368 output_options = SalesOrderAllocationOutputOptions 

1369 

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) 

1373 

1374 ordering_fields = [ 

1375 'quantity', 

1376 'part', 

1377 'serial', 

1378 'IPN', 

1379 'batch', 

1380 'location', 

1381 'order', 

1382 'shipment_date', 

1383 ] 

1384 

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 } 

1394 

1395 search_fields = { 

1396 'item__part__name', 

1397 'item__part__IPN', 

1398 'item__serial', 

1399 'item__batch', 

1400 } 

1401 

1402 

1403class SalesOrderAllocationDetail(SalesOrderAllocationMixin, RetrieveUpdateDestroyAPI): 

1404 """API endpoint for detail view of a SalesOrderAllocation object.""" 

1405 

1406 

1407class SalesOrderShipmentFilter(FilterSet): 

1408 """Custom filterset for the SalesOrderShipmentList endpoint.""" 

1409 

1410 class Meta: 

1411 """Metaclass options.""" 

1412 

1413 model = models.SalesOrderShipment 

1414 fields = ['order'] 

1415 

1416 checked = rest_filters.BooleanFilter(label='checked', method='filter_checked') 

1417 

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) 

1423 

1424 shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped') 

1425 

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) 

1431 

1432 delivered = rest_filters.BooleanFilter(label='delivered', method='filter_delivered') 

1433 

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) 

1439 

1440 order_outstanding = rest_filters.BooleanFilter( 

1441 label=_('Order Outstanding'), method='filter_order_outstanding' 

1442 ) 

1443 

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) 

1449 

1450 order_status = rest_filters.NumberFilter( 

1451 label=_('Order Status'), method='filter_order_status' 

1452 ) 

1453 

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) 

1458 

1459 return queryset.filter(q1 | q2).distinct() 

1460 

1461 tags = common.filters.TagsFilter() 

1462 

1463 

1464class SalesOrderShipmentMixin: 

1465 """Mixin class for SalesOrderShipment endpoints.""" 

1466 

1467 queryset = models.SalesOrderShipment.objects.all() 

1468 serializer_class = serializers.SalesOrderShipmentSerializer 

1469 

1470 def get_queryset(self, *args, **kwargs): 

1471 """Return annotated queryset for this endpoint.""" 

1472 queryset = super().get_queryset(*args, **kwargs) 

1473 

1474 queryset = serializers.SalesOrderShipmentSerializer.annotate_queryset(queryset) 

1475 

1476 return queryset 

1477 

1478 

1479class SalesOrderShipmentList(SalesOrderShipmentMixin, ListCreateAPI): 

1480 """API list endpoint for SalesOrderShipment model.""" 

1481 

1482 filterset_class = SalesOrderShipmentFilter 

1483 filter_backends = SEARCH_ORDER_FILTER 

1484 ordering_fields = ['reference', 'delivery_date', 'shipment_date', 'allocated_items'] 

1485 

1486 search_fields = [ 

1487 'order__reference', 

1488 'reference', 

1489 'tracking_number', 

1490 'invoice_number', 

1491 ] 

1492 

1493 

1494class SalesOrderShipmentDetail(SalesOrderShipmentMixin, RetrieveUpdateDestroyAPI): 

1495 """API detail endpoint for SalesOrderShipment model.""" 

1496 

1497 

1498class SalesOrderShipmentComplete(CreateAPI): 

1499 """API endpoint for completing (shipping) a SalesOrderShipment.""" 

1500 

1501 queryset = models.SalesOrderShipment.objects.all() 

1502 serializer_class = serializers.SalesOrderShipmentCompleteSerializer 

1503 

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')) 

1512 

1513 return shipment 

1514 

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() 

1520 

1521 return ctx 

1522 

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() 

1527 

1528 serializer = self.get_serializer(shipment, data=request.data, partial=True) 

1529 serializer.is_valid(raise_exception=True) 

1530 data = serializer.validated_data 

1531 

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 ) 

1540 

1541 response = common.serializers.TaskDetailSerializer.from_task(task_id).data 

1542 return Response(response, status=response['http_status']) 

1543 

1544 

1545class ReturnOrderFilter(OrderFilter): 

1546 """Custom API filters for the ReturnOrderList endpoint.""" 

1547 

1548 class Meta: 

1549 """Metaclass options.""" 

1550 

1551 model = models.ReturnOrder 

1552 fields = ['customer'] 

1553 

1554 include_variants = rest_filters.BooleanFilter( 

1555 label=_('Include Variants'), method='filter_include_variants' 

1556 ) 

1557 

1558 def filter_include_variants(self, queryset, name, value): 

1559 """Filter by whether or not to include variants of the selected part. 

1560 

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 

1566 

1567 part = rest_filters.ModelChoiceFilter( 

1568 queryset=Part.objects.all(), field_name='part', method='filter_part' 

1569 ) 

1570 

1571 @extend_schema_field(OpenApiTypes.INT) 

1572 def filter_part(self, queryset, name, part): 

1573 """Filter by selected 'part'. 

1574 

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)) 

1580 

1581 if include_variants: 

1582 parts = part.get_descendants(include_self=True) 

1583 else: 

1584 parts = Part.objects.filter(pk=part.pk) 

1585 

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) 

1588 

1589 # Generate a list of ID values for the matching return orders 

1590 return_orders = line_items.values_list('order', flat=True).distinct() 

1591 

1592 # Now we have a list of matching IDs, filter the queryset 

1593 return queryset.filter(pk__in=return_orders) 

1594 

1595 completed_before = InvenTreeDateFilter( 

1596 label=_('Completed Before'), field_name='complete_date', lookup_expr='lt' 

1597 ) 

1598 

1599 completed_after = InvenTreeDateFilter( 

1600 label=_('Completed After'), field_name='complete_date', lookup_expr='gt' 

1601 ) 

1602 

1603 

1604class ReturnOrderMixin(SerializerContextMixin): 

1605 """Mixin class for ReturnOrder endpoints.""" 

1606 

1607 queryset = models.ReturnOrder.objects.all() 

1608 serializer_class = serializers.ReturnOrderSerializer 

1609 

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 ) 

1617 

1618 return queryset 

1619 

1620 

1621class ReturnOrderOutputOptions(OutputConfiguration): 

1622 """Output options for the ReturnOrder endpoint.""" 

1623 

1624 OPTIONS = [InvenTreeOutputOption(flag='customer_detail')] 

1625 

1626 

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.""" 

1636 

1637 filterset_class = ReturnOrderFilter 

1638 filter_backends = SEARCH_ORDER_FILTER 

1639 

1640 output_options = ReturnOrderOutputOptions 

1641 

1642 ordering_field_aliases = { 

1643 'reference': ['reference_int', 'reference'], 

1644 'project_code': ['project_code__code'], 

1645 } 

1646 

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 ] 

1661 

1662 search_fields = [ 

1663 'customer__name', 

1664 'reference', 

1665 'description', 

1666 'customer_reference', 

1667 'project_code__code', 

1668 ] 

1669 

1670 ordering = '-reference' 

1671 

1672 

1673class ReturnOrderDetail(ReturnOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI): 

1674 """API endpoint for detail view of a single ReturnOrder object.""" 

1675 

1676 output_options = ReturnOrderOutputOptions 

1677 

1678 

1679class ReturnOrderContextMixin: 

1680 """Simple mixin class to add a ReturnOrder to the serializer context.""" 

1681 

1682 queryset = models.ReturnOrder.objects.all() 

1683 

1684 def get_serializer_context(self): 

1685 """Add the PurchaseOrder object to the serializer context.""" 

1686 context = super().get_serializer_context() 

1687 

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 

1695 

1696 context['request'] = self.request 

1697 

1698 return context 

1699 

1700 

1701class ReturnOrderCancel(ReturnOrderContextMixin, CreateAPI): 

1702 """API endpoint to cancel a ReturnOrder.""" 

1703 

1704 serializer_class = serializers.ReturnOrderCancelSerializer 

1705 

1706 

1707class ReturnOrderHold(ReturnOrderContextMixin, CreateAPI): 

1708 """API endpoint to hold a ReturnOrder.""" 

1709 

1710 serializer_class = serializers.ReturnOrderHoldSerializer 

1711 

1712 

1713class ReturnOrderComplete(ReturnOrderContextMixin, CreateAPI): 

1714 """API endpoint to complete a ReturnOrder.""" 

1715 

1716 serializer_class = serializers.ReturnOrderCompleteSerializer 

1717 

1718 

1719class ReturnOrderIssue(ReturnOrderContextMixin, CreateAPI): 

1720 """API endpoint to issue (place) a ReturnOrder.""" 

1721 

1722 serializer_class = serializers.ReturnOrderIssueSerializer 

1723 

1724 

1725class ReturnOrderReceive(ReturnOrderContextMixin, CreateAPI): 

1726 """API endpoint to receive items against a ReturnOrder.""" 

1727 

1728 queryset = models.ReturnOrder.objects.none() 

1729 serializer_class = serializers.ReturnOrderReceiveSerializer 

1730 

1731 

1732class ReturnOrderLineItemFilter(LineItemFilter): 

1733 """Custom filters for the ReturnOrderLineItemList endpoint.""" 

1734 

1735 class Meta: 

1736 """Metaclass options.""" 

1737 

1738 price_field = 'price' 

1739 model = models.ReturnOrderLineItem 

1740 fields = ['order', 'item'] 

1741 

1742 outcome = rest_filters.NumberFilter(label='outcome') 

1743 

1744 received = rest_filters.BooleanFilter(label='received', method='filter_received') 

1745 

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) 

1751 

1752 

1753class ReturnOrderLineItemMixin(SerializerContextMixin): 

1754 """Mixin class for ReturnOrderLineItem endpoints.""" 

1755 

1756 queryset = models.ReturnOrderLineItem.objects.all() 

1757 serializer_class = serializers.ReturnOrderLineItemSerializer 

1758 

1759 def get_queryset(self, *args, **kwargs): 

1760 """Return annotated queryset for this endpoint.""" 

1761 queryset = super().get_queryset(*args, **kwargs) 

1762 

1763 queryset = queryset.prefetch_related('order', 'item', 'item__part') 

1764 

1765 return queryset 

1766 

1767 

1768class ReturnOrderLineItemOutputOptions(OutputConfiguration): 

1769 """Output options for the ReturnOrderLineItem endpoint.""" 

1770 

1771 OPTIONS = [ 

1772 InvenTreeOutputOption('part_detail'), 

1773 InvenTreeOutputOption('item_detail', default=True), 

1774 InvenTreeOutputOption('order_detail'), 

1775 ] 

1776 

1777 

1778class ReturnOrderLineItemList( 

1779 ReturnOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI 

1780): 

1781 """API endpoint for accessing a list of ReturnOrderLineItemList objects.""" 

1782 

1783 filterset_class = ReturnOrderLineItemFilter 

1784 

1785 filter_backends = SEARCH_ORDER_FILTER 

1786 

1787 output_options = ReturnOrderLineItemOutputOptions 

1788 

1789 ordering_fields = [ 

1790 'part', 

1791 'IPN', 

1792 'stock', 

1793 'reference', 

1794 'target_date', 

1795 'received_date', 

1796 'line', 

1797 ] 

1798 

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 } 

1805 

1806 search_fields = [ 

1807 'item__serial', 

1808 'item__part__name', 

1809 'item__part__description', 

1810 'reference', 

1811 ] 

1812 

1813 

1814class ReturnOrderLineItemDetail( 

1815 ReturnOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI 

1816): 

1817 """API endpoint for detail view of a ReturnOrderLineItem object.""" 

1818 

1819 output_options = ReturnOrderLineItemOutputOptions 

1820 

1821 

1822class ReturnOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI): 

1823 """API endpoint for accessing a list of ReturnOrderExtraLine objects.""" 

1824 

1825 queryset = models.ReturnOrderExtraLine.objects.all() 

1826 serializer_class = serializers.ReturnOrderExtraLineSerializer 

1827 

1828 

1829class ReturnOrderExtraLineDetail(RetrieveUpdateDestroyAPI): 

1830 """API endpoint for detail view of a ReturnOrderExtraLine object.""" 

1831 

1832 queryset = models.ReturnOrderExtraLine.objects.all() 

1833 serializer_class = serializers.ReturnOrderExtraLineSerializer 

1834 

1835 

1836class TransferOrderFilter(OrderFilter): 

1837 """Custom API filters for the TransferOrderList endpoint.""" 

1838 

1839 class Meta: 

1840 """Metaclass options.""" 

1841 

1842 model = models.TransferOrder 

1843 fields = [] 

1844 

1845 include_variants = rest_filters.BooleanFilter( 

1846 label=_('Include Variants'), method='filter_include_variants' 

1847 ) 

1848 

1849 def filter_include_variants(self, queryset, name, value): 

1850 """Filter by whether or not to include variants of the selected part. 

1851 

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 

1857 

1858 part = rest_filters.ModelChoiceFilter( 

1859 queryset=Part.objects.all(), field_name='part', method='filter_part' 

1860 ) 

1861 

1862 @extend_schema_field(OpenApiTypes.INT) 

1863 def filter_part(self, queryset, name, part): 

1864 """Filter by selected 'part'. 

1865 

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)) 

1871 

1872 if include_variants: 

1873 parts = part.get_descendants(include_self=True) 

1874 else: 

1875 parts = Part.objects.filter(pk=part.pk) 

1876 

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) 

1879 

1880 # Generate a list of ID values for the matching transfer orders 

1881 transfer_orders = line_items.values_list('order', flat=True).distinct() 

1882 

1883 # Now we have a list of matching IDs, filter the queryset 

1884 return queryset.filter(pk__in=transfer_orders) 

1885 

1886 completed_before = InvenTreeDateFilter( 

1887 label=_('Completed Before'), field_name='complete_date', lookup_expr='lt' 

1888 ) 

1889 

1890 completed_after = InvenTreeDateFilter( 

1891 label=_('Completed After'), field_name='complete_date', lookup_expr='gt' 

1892 ) 

1893 

1894 

1895class TransferOrderMixin(SerializerContextMixin): 

1896 """Mixin class for TransferOrder endpoints.""" 

1897 

1898 queryset = models.TransferOrder.objects.all() 

1899 serializer_class = serializers.TransferOrderSerializer 

1900 

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') 

1906 

1907 return queryset 

1908 

1909 

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.""" 

1919 

1920 filterset_class = TransferOrderFilter 

1921 filter_backends = SEARCH_ORDER_FILTER 

1922 

1923 # TODO: 

1924 # output_options = TransferOrderOutputOptions 

1925 

1926 ordering_field_aliases = { 

1927 'reference': ['reference_int', 'reference'], 

1928 'project_code': ['project_code__code'], 

1929 } 

1930 

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 ] 

1942 

1943 search_fields = ['reference', 'description', 'project_code__code'] 

1944 

1945 ordering = '-reference' 

1946 

1947 

1948class TransferOrderDetail( 

1949 TransferOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI 

1950): 

1951 """API endpoint for detail view of a single TransferOrder object.""" 

1952 

1953 # output_options = TransferOrderOutputOptions 

1954 

1955 

1956class TransferOrderContextMixin: 

1957 """Simple mixin class to add a TransferOrder to the serializer context.""" 

1958 

1959 queryset = models.TransferOrder.objects.all() 

1960 

1961 def get_serializer_context(self): 

1962 """Add the TransferOrder object to the serializer context.""" 

1963 context = super().get_serializer_context() 

1964 

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 

1972 

1973 context['request'] = self.request 

1974 

1975 return context 

1976 

1977 

1978class TransferOrderCancel(TransferOrderContextMixin, CreateAPI): 

1979 """API endpoint to cancel a TransferOrder.""" 

1980 

1981 serializer_class = serializers.TransferOrderCancelSerializer 

1982 

1983 

1984class TransferOrderHold(TransferOrderContextMixin, CreateAPI): 

1985 """API endpoint to hold a TransferOrder.""" 

1986 

1987 serializer_class = serializers.TransferOrderHoldSerializer 

1988 

1989 

1990class TransferOrderComplete(TransferOrderContextMixin, CreateAPI): 

1991 """API endpoint to complete a TransferOrder.""" 

1992 

1993 serializer_class = serializers.TransferOrderCompleteSerializer 

1994 

1995 

1996class TransferOrderIssue(TransferOrderContextMixin, CreateAPI): 

1997 """API endpoint to issue a Transfer Order.""" 

1998 

1999 serializer_class = serializers.TransferOrderIssueSerializer 

2000 

2001 

2002class TransferOrderAllocateSerials(TransferOrderContextMixin, CreateAPI): 

2003 """API endpoint to allocation stock items against a TransferOrder, by specifying serial numbers.""" 

2004 

2005 queryset = models.TransferOrder.objects.none() 

2006 serializer_class = serializers.TransferOrderSerialAllocationSerializer 

2007 

2008 

2009class TransferOrderAllocate(TransferOrderContextMixin, CreateAPI): 

2010 """API endpoint to allocate stock items against a TransferOrder. 

2011 

2012 - The TransferOrder is specified in the URL 

2013 - See the TransferOrderAllocationSerializer class 

2014 """ 

2015 

2016 queryset = models.TransferOrder.objects.none() 

2017 serializer_class = serializers.TransferOrderLineItemAllocationSerializer 

2018 

2019 

2020class TransferOrderAllocationFilter(FilterSet): 

2021 """Custom filterset for the TransferOrderAllocationList endpoint.""" 

2022 

2023 class Meta: 

2024 """Metaclass options.""" 

2025 

2026 model = models.TransferOrderAllocation 

2027 fields = ['line', 'item'] 

2028 

2029 order = rest_filters.ModelChoiceFilter( 

2030 queryset=models.TransferOrder.objects.all(), 

2031 field_name='line__order', 

2032 label=_('Order'), 

2033 ) 

2034 

2035 include_variants = rest_filters.BooleanFilter( 

2036 label=_('Include Variants'), method='filter_include_variants' 

2037 ) 

2038 

2039 def filter_include_variants(self, queryset, name, value): 

2040 """Filter by whether or not to include variants of the selected part. 

2041 

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 

2047 

2048 part = rest_filters.ModelChoiceFilter( 

2049 queryset=Part.objects.all(), method='filter_part', label=_('Part') 

2050 ) 

2051 

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. 

2055 

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)) 

2061 

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) 

2067 

2068 outstanding = rest_filters.BooleanFilter( 

2069 label=_('Outstanding'), method='filter_outstanding' 

2070 ) 

2071 

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 ) 

2085 

2086 location = rest_filters.ModelChoiceFilter( 

2087 queryset=stock_models.StockLocation.objects.all(), 

2088 label=_('Location'), 

2089 method='filter_location', 

2090 ) 

2091 

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) 

2099 

2100 

2101class TransferOrderAllocationMixin: 

2102 """Mixin class for TransferOrderAllocation endpoints.""" 

2103 

2104 queryset = models.TransferOrderAllocation.objects.all() 

2105 serializer_class = serializers.TransferOrderAllocationSerializer 

2106 

2107 def get_queryset(self, *args, **kwargs): 

2108 """Annotate the queryset for this endpoint.""" 

2109 queryset = super().get_queryset(*args, **kwargs) 

2110 

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') 

2122 

2123 return queryset 

2124 

2125 

2126class TransferOrderAllocationOutputOptions(OutputConfiguration): 

2127 """Output options for the TransferOrderAllocation endpoint.""" 

2128 

2129 OPTIONS = [ 

2130 InvenTreeOutputOption('part_detail'), 

2131 InvenTreeOutputOption('item_detail'), 

2132 InvenTreeOutputOption('order_detail'), 

2133 InvenTreeOutputOption('location_detail'), 

2134 ] 

2135 

2136 

2137class TransferOrderAllocationList( 

2138 TransferOrderAllocationMixin, BulkUpdateMixin, OutputOptionsMixin, ListAPI 

2139): 

2140 """API endpoint for listing TransferOrderAllocation objects.""" 

2141 

2142 filterset_class = TransferOrderAllocationFilter 

2143 filter_backends = SEARCH_ORDER_FILTER 

2144 output_options = TransferOrderAllocationOutputOptions 

2145 

2146 ordering_fields = [ 

2147 'quantity', 

2148 'part', 

2149 'serial', 

2150 'IPN', 

2151 'batch', 

2152 'location', 

2153 'order', 

2154 ] 

2155 

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 } 

2164 

2165 search_fields = { 

2166 'item__part__name', 

2167 'item__part__IPN', 

2168 'item__serial', 

2169 'item__batch', 

2170 } 

2171 

2172 

2173class TransferOrderAllocationDetail( 

2174 TransferOrderAllocationMixin, RetrieveUpdateDestroyAPI 

2175): 

2176 """API endpoint for detail view of a TransferOrderAllocation object.""" 

2177 

2178 

2179class TransferOrderLineItemFilter(LineItemFilter): 

2180 """Custom filters for TransferOrderLineItemList endpoint.""" 

2181 

2182 class Meta: 

2183 """Metaclass options.""" 

2184 

2185 model = models.TransferOrderLineItem 

2186 fields = [] 

2187 

2188 order = rest_filters.ModelChoiceFilter( 

2189 queryset=models.TransferOrder.objects.all(), 

2190 field_name='order', 

2191 label=_('Order'), 

2192 ) 

2193 

2194 def filter_include_variants(self, queryset, name, value): 

2195 """Filter by whether or not to include variants of the selected part. 

2196 

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 

2202 

2203 part = rest_filters.ModelChoiceFilter( 

2204 queryset=Part.objects.all(), 

2205 field_name='part', 

2206 label=_('Part'), 

2207 method='filter_part', 

2208 ) 

2209 

2210 @extend_schema_field(OpenApiTypes.INT) 

2211 def filter_part(self, queryset, name, part): 

2212 """Filter TransferOrderLineItem by selected 'part'. 

2213 

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)) 

2219 

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) 

2225 

2226 return queryset.filter(part__in=parts) 

2227 

2228 allocated = rest_filters.BooleanFilter( 

2229 label=_('Allocated'), method='filter_allocated' 

2230 ) 

2231 

2232 def filter_allocated(self, queryset, name, value): 

2233 """Filter by lines which are 'allocated'. 

2234 

2235 A line is 'allocated' when allocated >= quantity 

2236 """ 

2237 q = Q(allocated__gte=F('quantity')) 

2238 

2239 if str2bool(value): 

2240 return queryset.filter(q) 

2241 return queryset.exclude(q) 

2242 

2243 completed = rest_filters.BooleanFilter( 

2244 label=_('Completed'), method='filter_completed' 

2245 ) 

2246 

2247 def filter_completed(self, queryset, name, value): 

2248 """Filter by lines which are "completed". 

2249 

2250 A line is 'completed' when transferred >= quantity 

2251 """ 

2252 q = Q(transferred__gte=F('quantity')) 

2253 

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) 

2257 

2258 order_complete = rest_filters.BooleanFilter( 

2259 label=_('Order Complete'), method='filter_order_complete' 

2260 ) 

2261 

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) 

2266 

2267 return queryset.exclude(order__status__in=TransferOrderStatusGroups.COMPLETE) 

2268 

2269 order_outstanding = rest_filters.BooleanFilter( 

2270 label=_('Order Outstanding'), method='filter_order_outstanding' 

2271 ) 

2272 

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) 

2277 

2278 return queryset.exclude(order__status__in=TransferOrderStatusGroups.OPEN) 

2279 

2280 

2281class TransferOrderLineItemMixin(SerializerContextMixin): 

2282 """Mixin class for TransferOrderLineItem endpoints.""" 

2283 

2284 queryset = models.TransferOrderLineItem.objects.all() 

2285 serializer_class = serializers.TransferOrderLineItemSerializer 

2286 

2287 def get_queryset(self, *args, **kwargs): 

2288 """Return annotated queryset for this endpoint.""" 

2289 queryset = super().get_queryset(*args, **kwargs) 

2290 

2291 queryset = queryset.prefetch_related( 

2292 'part', 

2293 'allocations', 

2294 # 'allocations__transfer', 

2295 'allocations__item__part', 

2296 'allocations__item__location', 

2297 'order', 

2298 ) 

2299 

2300 queryset = serializers.TransferOrderLineItemSerializer.annotate_queryset( 

2301 queryset 

2302 ) 

2303 

2304 return queryset 

2305 

2306 

2307class TransferOrderLineItemOutputOptions(OutputConfiguration): 

2308 """Output options for the TransferOrderAllocation endpoint.""" 

2309 

2310 OPTIONS = [ 

2311 InvenTreeOutputOption('part_detail'), 

2312 InvenTreeOutputOption('order_detail'), 

2313 ] 

2314 

2315 

2316class TransferOrderLineItemList( 

2317 TransferOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI 

2318): 

2319 """API endpoint for accessing a list of TransferOrderLineItem objects.""" 

2320 

2321 filterset_class = TransferOrderLineItemFilter 

2322 

2323 filter_backends = SEARCH_ORDER_FILTER 

2324 

2325 output_options = TransferOrderLineItemOutputOptions 

2326 

2327 ordering_fields = [ 

2328 'order', 

2329 'part', 

2330 'part__name', 

2331 'quantity', 

2332 'allocated', 

2333 'transferred', 

2334 'reference', 

2335 'target_date', 

2336 ] 

2337 

2338 ordering_field_aliases = {'part': 'part__name', 'order': 'order__reference'} 

2339 

2340 search_fields = ['part__name', 'quantity', 'reference'] 

2341 

2342 

2343class TransferOrderLineItemDetail( 

2344 TransferOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI 

2345): 

2346 """API endpoint for detail view of a TransferOrderLineItem object.""" 

2347 

2348 output_options = TransferOrderLineItemOutputOptions 

2349 

2350 

2351class OrderCalendarExport(ICalFeed): 

2352 """Calendar export for Purchase/Sales Orders. 

2353 

2354 Optional parameters: 

2355 - include_completed: true/false 

2356 whether or not to show completed orders. Defaults to false 

2357 """ 

2358 

2359 instance_url = get_base_url() 

2360 

2361 instance_url = instance_url.replace('http://', '').replace('https://', '') 

2362 timezone = settings.TIME_ZONE 

2363 file_name = 'calendar.ics' 

2364 

2365 def __call__(self, request, *args, **kwargs): 

2366 """Overload call in order to check for authentication. 

2367 

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. 

2371 

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 

2378 

2379 if request.user.is_authenticated: 

2380 # Authenticated on first try - maybe normal browser call? 

2381 return super().__call__(request, *args, **kwargs) 

2382 

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 

2395 

2396 # Check again 

2397 if request.user.is_authenticated: 

2398 # Authenticated after second try 

2399 return super().__call__(request, *args, **kwargs) 

2400 

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 

2409 

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 

2414 

2415 obj = {} 

2416 obj['ordertype'] = kwargs['ordertype'] 

2417 obj['include_completed'] = bool(request.GET.get('include_completed', False)) 

2418 

2419 return obj 

2420 

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') 

2433 

2434 company_name = common.settings.get_global_setting('INVENTREE_COMPANY_NAME') 

2435 

2436 return f'{company_name} {ordertype_title}' 

2437 

2438 def product_id(self, obj): 

2439 """Return calendar product id.""" 

2440 return f'//{self.instance_url}//{self.title(obj)}//EN' 

2441 

2442 def items(self, obj): 

2443 """Return a list of Orders. 

2444 

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 = [] 

2487 

2488 return outlist 

2489 

2490 def item_title(self, item): 

2491 """Set the event title to the order reference.""" 

2492 return f'{item.reference}' 

2493 

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 ) 

2502 

2503 def item_start_datetime(self, item): 

2504 """Set event start to target date. Goal is all-day event.""" 

2505 return item.target_date 

2506 

2507 def item_end_datetime(self, item): 

2508 """Set event end to target date. Goal is all-day event.""" 

2509 return item.target_date 

2510 

2511 def item_created(self, item): 

2512 """Use creation date of PO as creation date of event.""" 

2513 return item.creation_date 

2514 

2515 def item_class(self, item): 

2516 """Set item class to PUBLIC.""" 

2517 return 'PUBLIC' 

2518 

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}' 

2522 

2523 def item_link(self, item): 

2524 """Set the item link.""" 

2525 return construct_absolute_url(item.get_absolute_url()) 

2526 

2527 

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]