Coverage for src/backend/InvenTree/stock/api.py: 60%

624 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 17:47 +0000

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

2 

3from collections import OrderedDict 

4from datetime import timedelta 

5 

6from django.core.exceptions import ValidationError as DjangoValidationError 

7from django.db import transaction 

8from django.db.models import F, Q 

9from django.urls import include, path 

10from django.utils.translation import gettext_lazy as _ 

11 

12import django_filters.rest_framework.filters as rest_filters 

13from django_filters.rest_framework.filterset import FilterSet 

14from drf_spectacular.types import OpenApiTypes 

15from drf_spectacular.utils import extend_schema, extend_schema_field 

16from rest_framework import status 

17from rest_framework.generics import GenericAPIView 

18from rest_framework.response import Response 

19from rest_framework.serializers import ValidationError 

20 

21import common.filters 

22import common.models 

23import common.settings 

24import InvenTree.helpers 

25import InvenTree.permissions 

26import stock.serializers as StockSerializers 

27from build.models import Build 

28from build.serializers import BuildSerializer 

29from company.models import Company, ManufacturerPart, SupplierPart 

30from company.serializers import CompanySerializer 

31from data_exporter.mixins import DataExportViewMixin 

32from generic.states.api import StatusView 

33from InvenTree.api import ( 

34 BulkCreateMixin, 

35 BulkUpdateMixin, 

36 ListCreateDestroyAPIView, 

37 TreeMixin, 

38 meta_path, 

39) 

40from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration 

41from InvenTree.filters import ( 

42 SEARCH_ORDER_FILTER, 

43 InvenTreeDateFilter, 

44 NumberOrNullFilter, 

45) 

46from InvenTree.helpers import extract_serial_numbers, generateTestKey, str2bool 

47from InvenTree.mixins import ( 

48 CreateAPI, 

49 CustomRetrieveUpdateDestroyAPI, 

50 ListAPI, 

51 ListCreateAPI, 

52 OutputOptionsMixin, 

53 RetrieveAPI, 

54 RetrieveUpdateDestroyAPI, 

55 SerializerContextMixin, 

56) 

57from order.models import PurchaseOrder, ReturnOrder, SalesOrder, TransferOrder 

58from order.serializers import ( 

59 PurchaseOrderSerializer, 

60 ReturnOrderSerializer, 

61 SalesOrderSerializer, 

62 TransferOrderSerializer, 

63) 

64from part.models import BomItem, Part, PartCategory 

65from part.serializers import PartBriefSerializer 

66from stock.generators import generate_batch_code, generate_serial_number 

67from stock.models import ( 

68 StockItem, 

69 StockItemTestResult, 

70 StockItemTracking, 

71 StockLocation, 

72 StockLocationType, 

73) 

74from stock.status_codes import StockHistoryCode, StockStatus 

75 

76 

77class GenerateBatchCode(GenericAPIView): 

78 """API endpoint for generating batch codes.""" 

79 

80 permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope] 

81 serializer_class = StockSerializers.GenerateBatchCodeSerializer 

82 

83 def post(self, request, *args, **kwargs): 

84 """Generate a new batch code.""" 

85 serializer = self.get_serializer(data=request.data) 

86 serializer.is_valid(raise_exception=True) 

87 

88 data = {'batch_code': generate_batch_code(**serializer.validated_data)} 

89 

90 return Response(data, status=status.HTTP_201_CREATED) 

91 

92 

93class GenerateSerialNumber(GenericAPIView): 

94 """API endpoint for generating serial numbers.""" 

95 

96 permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope] 

97 serializer_class = StockSerializers.GenerateSerialNumberSerializer 

98 

99 def post(self, request, *args, **kwargs): 

100 """Generate a new serial number.""" 

101 serializer = self.get_serializer(data=request.data) 

102 serializer.is_valid(raise_exception=True) 

103 

104 data = {'serial_number': generate_serial_number(**serializer.validated_data)} 

105 

106 return Response(data, status=status.HTTP_201_CREATED) 

107 

108 

109class StockItemContextMixin: 

110 """Mixin class for adding StockItem object to serializer context.""" 

111 

112 role_required = 'stock.change' 

113 

114 queryset = StockItem.objects.none() 

115 

116 def get_serializer_context(self): 

117 """Extend serializer context.""" 

118 context = super().get_serializer_context() 

119 context['request'] = self.request 

120 

121 try: 

122 context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None)) 

123 except Exception: # pragma: no cover 

124 pass 

125 

126 return context 

127 

128 

129@extend_schema(responses={201: StockSerializers.StockItemSerializer(many=True)}) 

130class StockItemSerialize(StockItemContextMixin, CreateAPI): 

131 """API endpoint for serializing a stock item.""" 

132 

133 serializer_class = StockSerializers.SerializeStockItemSerializer 

134 pagination_class = None 

135 

136 def create(self, request, *args, **kwargs): 

137 """Serialize the provided StockItem.""" 

138 serializer = self.get_serializer(data=request.data) 

139 serializer.is_valid(raise_exception=True) 

140 

141 # Perform the actual serialization step 

142 items = serializer.save() 

143 

144 queryset = StockSerializers.StockItemSerializer.annotate_queryset(items) 

145 

146 response = StockSerializers.StockItemSerializer( 

147 queryset, many=True, context=self.get_serializer_context() 

148 ) 

149 

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

151 

152 

153class StockItemInstall(StockItemContextMixin, CreateAPI): 

154 """API endpoint for installing a particular stock item into this stock item. 

155 

156 - stock_item.part must be in the BOM for this part 

157 - stock_item must currently be "in stock" 

158 - stock_item must be serialized (and not belong to another item) 

159 """ 

160 

161 serializer_class = StockSerializers.InstallStockItemSerializer 

162 

163 

164class StockItemUninstall(StockItemContextMixin, CreateAPI): 

165 """API endpoint for removing (uninstalling) items from this item.""" 

166 

167 serializer_class = StockSerializers.UninstallStockItemSerializer 

168 

169 

170class StockItemConvert(StockItemContextMixin, CreateAPI): 

171 """API endpoint for converting a stock item to a variant part.""" 

172 

173 serializer_class = StockSerializers.ConvertStockItemSerializer 

174 

175 

176class StockAdjustView(CreateAPI): 

177 """A generic class for handling stocktake actions. 

178 

179 Subclasses exist for: 

180 

181 - StockCount: count stock items 

182 - StockAdd: add stock items 

183 - StockRemove: remove stock items 

184 - StockTransfer: transfer stock items 

185 """ 

186 

187 queryset = StockItem.objects.none() 

188 

189 def get_serializer_context(self): 

190 """Extend serializer context.""" 

191 context = super().get_serializer_context() 

192 context['request'] = self.request 

193 

194 return context 

195 

196 

197class StockChangeStatus(StockAdjustView): 

198 """API endpoint to change the status code of multiple StockItem objects.""" 

199 

200 serializer_class = StockSerializers.StockChangeStatusSerializer 

201 

202 

203class StockCount(StockAdjustView): 

204 """Endpoint for counting stock (performing a stocktake).""" 

205 

206 serializer_class = StockSerializers.StockCountSerializer 

207 

208 

209class StockAdd(StockAdjustView): 

210 """Endpoint for adding a quantity of stock to an existing StockItem.""" 

211 

212 serializer_class = StockSerializers.StockAddSerializer 

213 

214 

215class StockRemove(StockAdjustView): 

216 """Endpoint for removing a quantity of stock from an existing StockItem.""" 

217 

218 serializer_class = StockSerializers.StockRemoveSerializer 

219 

220 

221class StockTransfer(StockAdjustView): 

222 """API endpoint for performing stock movements.""" 

223 

224 serializer_class = StockSerializers.StockTransferSerializer 

225 

226 

227class StockReturn(StockAdjustView): 

228 """API endpoint for returning items into stock. 

229 

230 This API endpoint is for items that are initially considered "not in stock", 

231 and the user wants to return them to stock, marking them as 

232 "available" for further consumption or sale. 

233 """ 

234 

235 serializer_class = StockSerializers.StockReturnSerializer 

236 

237 

238class StockAssign(CreateAPI): 

239 """API endpoint for assigning stock to a particular customer.""" 

240 

241 queryset = StockItem.objects.all() 

242 serializer_class = StockSerializers.StockAssignmentSerializer 

243 

244 def get_serializer_context(self): 

245 """Extend serializer context.""" 

246 ctx = super().get_serializer_context() 

247 ctx['request'] = self.request 

248 

249 return ctx 

250 

251 

252class StockMerge(CreateAPI): 

253 """API endpoint for merging multiple stock items.""" 

254 

255 queryset = StockItem.objects.none() 

256 serializer_class = StockSerializers.StockMergeSerializer 

257 

258 def get_serializer_context(self): 

259 """Extend serializer context.""" 

260 ctx = super().get_serializer_context() 

261 ctx['request'] = self.request 

262 return ctx 

263 

264 

265class StockLocationFilter(FilterSet): 

266 """Base class for custom API filters for the StockLocation endpoint.""" 

267 

268 class Meta: 

269 """Meta class options for this filterset.""" 

270 

271 model = StockLocation 

272 fields = ['name', 'structural', 'external'] 

273 

274 location_type = rest_filters.ModelChoiceFilter( 

275 queryset=StockLocationType.objects.all(), field_name='location_type' 

276 ) 

277 

278 has_location_type = rest_filters.BooleanFilter( 

279 label='has_location_type', method='filter_has_location_type' 

280 ) 

281 

282 def filter_has_location_type(self, queryset, name, value): 

283 """Filter by whether or not the location has a location type.""" 

284 if str2bool(value): 

285 return queryset.exclude(location_type=None) 

286 return queryset.filter(location_type=None) 

287 

288 depth = rest_filters.NumberFilter( 

289 label=_('Depth'), method='filter_depth', help_text=_('Filter by location depth') 

290 ) 

291 

292 def filter_depth(self, queryset, name, value): 

293 """Filter by the "depth" of the StockLocation. 

294 

295 - This filter is used to limit the depth of the location tree 

296 - If the "parent" filter is also provided, the depth is calculated from the parent location 

297 """ 

298 parent = self.data.get('parent', None) 

299 

300 # Only filter if the parent filter is *not* provided 

301 if not parent: 301 ↛ 304line 301 didn't jump to line 304 because the condition on line 301 was always true

302 queryset = queryset.filter(level__lte=value) 

303 

304 return queryset 

305 

306 top_level = rest_filters.BooleanFilter( 

307 label=_('Top Level'), 

308 method='filter_top_level', 

309 help_text=_('Filter by top-level locations'), 

310 ) 

311 

312 def filter_top_level(self, queryset, name, value): 

313 """Filter by top-level locations.""" 

314 cascade = str2bool(self.data.get('cascade', False)) 

315 

316 if value and not cascade: 

317 return queryset.filter(parent=None) 

318 

319 return queryset 

320 

321 cascade = rest_filters.BooleanFilter( 

322 label=_('Cascade'), 

323 method='filter_cascade', 

324 help_text=_('Include sub-locations in filtered results'), 

325 ) 

326 

327 def filter_cascade(self, queryset, name, value): 

328 """Filter by whether to include sub-locations in the filtered results. 

329 

330 Note: If the "parent" filter is provided, we offload the logic to that method. 

331 """ 

332 parent = self.data.get('parent', None) 

333 top_level = str2bool(self.data.get('top_level', None)) 

334 

335 # If the parent is *not* provided, update the results based on the "cascade" value 

336 if (not parent or top_level) and not value: 

337 # If "cascade" is False, only return top-level location 

338 queryset = queryset.filter(parent=None) 

339 

340 return queryset 

341 

342 parent = rest_filters.ModelChoiceFilter( 

343 queryset=StockLocation.objects.all(), 

344 method='filter_parent', 

345 label=_('Parent Location'), 

346 help_text=_('Filter by parent location'), 

347 ) 

348 

349 def filter_parent(self, queryset, name, value): 

350 """Filter by parent location. 

351 

352 Note that the filtering behavior here varies, 

353 depending on whether the 'cascade' value is set. 

354 

355 So, we have to check the "cascade" value here. 

356 """ 

357 parent = value 

358 depth = self.data.get('depth', None) 

359 cascade = str2bool(self.data.get('cascade', False)) 

360 

361 if cascade: 

362 # Return recursive sub-locations 

363 queryset = queryset.filter( 

364 parent__in=parent.get_descendants(include_self=True) 

365 ) 

366 else: 

367 # Return only direct children 

368 queryset = queryset.filter(parent=parent) 

369 

370 if depth is not None: 

371 # Filter by depth from parent 

372 depth = int(depth) 

373 queryset = queryset.filter(level__lte=parent.level + depth) 

374 

375 return queryset 

376 

377 tags = common.filters.TagsFilter(label=_('Tags')) 

378 

379 

380class StockLocationMixin(SerializerContextMixin): 

381 """Mixin class for StockLocation API endpoints.""" 

382 

383 queryset = StockLocation.objects.all() 

384 serializer_class = StockSerializers.LocationSerializer 

385 

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

387 """Return annotated queryset for the StockLocationList endpoint.""" 

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

389 queryset = StockSerializers.LocationSerializer.annotate_queryset(queryset) 

390 return queryset 

391 

392 

393class StockLocationOutputOptions(OutputConfiguration): 

394 """Output options for StockLocation serializers.""" 

395 

396 OPTIONS = [ 

397 InvenTreeOutputOption( 

398 description='Include detailed information about the BOM item linked to this build line.', 

399 flag='path_detail', 

400 ) 

401 ] 

402 

403 

404class StockLocationList( 

405 DataExportViewMixin, 

406 BulkUpdateMixin, 

407 StockLocationMixin, 

408 OutputOptionsMixin, 

409 ListCreateAPI, 

410): 

411 """API endpoint for list view of StockLocation objects. 

412 

413 - GET: Return list of StockLocation objects 

414 - POST: Create a new StockLocation 

415 """ 

416 

417 filterset_class = StockLocationFilter 

418 filter_backends = SEARCH_ORDER_FILTER 

419 output_options = StockLocationOutputOptions 

420 

421 search_fields = ['name', 'description', 'pathstring', 'tags__name', 'tags__slug'] 

422 

423 ordering_fields = ['name', 'pathstring', 'items', 'level', 'tree_id', 'lft'] 

424 

425 ordering = ['tree_id', 'lft', 'name'] 

426 

427 

428class StockLocationDetail( 

429 StockLocationMixin, OutputOptionsMixin, CustomRetrieveUpdateDestroyAPI 

430): 

431 """API endpoint for detail view of StockLocation object.""" 

432 

433 output_options = StockLocationOutputOptions 

434 

435 def destroy(self, request, *args, **kwargs): 

436 """Delete a Stock location instance via the API.""" 

437 serializer = StockSerializers.LocationDeleteSerializer(data=request.data) 

438 serializer.is_valid(raise_exception=True) 

439 

440 delete_stock_items = InvenTree.helpers.str2bool( 

441 serializer.validated_data.get('delete_stock_items', False) 

442 ) 

443 

444 delete_sub_locations = InvenTree.helpers.str2bool( 

445 serializer.validated_data.get('delete_sub_locations', False) 

446 ) 

447 

448 return super().destroy( 

449 request, 

450 *args, 

451 **{ 

452 **kwargs, 

453 'delete_sub_locations': delete_sub_locations, 

454 'delete_stock_items': delete_stock_items, 

455 }, 

456 ) 

457 

458 

459class LocationTreeFilter(FilterSet): 

460 """Custom filterset class for the StockLocationTree endpoint.""" 

461 

462 class Meta: 

463 """Metaclass options for this filterset.""" 

464 

465 model = StockLocation 

466 fields = ['parent', 'tree_id', 'level'] 

467 

468 max_level = rest_filters.NumberFilter( 

469 label=_('Max Level'), 

470 method='filter_max_level', 

471 help_text=_('Limit the depth of the category tree'), 

472 ) 

473 

474 def filter_max_level(self, queryset, name, value): 

475 """Filter by the maximum depth of the category tree.""" 

476 return queryset.filter(level__lte=value) 

477 

478 

479class StockLocationTree(TreeMixin, ListAPI): 

480 """API endpoint for accessing a list of StockLocation objects, ready for rendering as a tree.""" 

481 

482 model_class = StockLocation 

483 queryset = StockLocation.objects.all() 

484 serializer_class = StockSerializers.LocationTreeSerializer 

485 filterset_class = LocationTreeFilter 

486 

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

488 """Return annotated queryset for the StockLocationTree endpoint.""" 

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

490 queryset = StockSerializers.LocationTreeSerializer.annotate_queryset(queryset) 

491 return queryset 

492 

493 

494class StockLocationTypeList(ListCreateAPI): 

495 """API endpoint for a list of StockLocationType objects. 

496 

497 - GET: Return a list of all StockLocationType objects 

498 - POST: Create a StockLocationType 

499 """ 

500 

501 queryset = StockLocationType.objects.all() 

502 serializer_class = StockSerializers.StockLocationTypeSerializer 

503 

504 filter_backends = SEARCH_ORDER_FILTER 

505 

506 ordering_fields = ['name', 'location_count', 'icon'] 

507 

508 ordering = ['-location_count'] 

509 

510 search_fields = ['name'] 

511 

512 def get_queryset(self): 

513 """Override the queryset method to include location count.""" 

514 queryset = super().get_queryset() 

515 queryset = StockSerializers.StockLocationTypeSerializer.annotate_queryset( 

516 queryset 

517 ) 

518 

519 return queryset 

520 

521 

522class StockLocationTypeDetail(RetrieveUpdateDestroyAPI): 

523 """API detail endpoint for a StockLocationType object. 

524 

525 - GET: return a single StockLocationType 

526 - PUT: update a StockLocationType 

527 - PATCH: partial update a StockLocationType 

528 - DELETE: delete a StockLocationType 

529 """ 

530 

531 queryset = StockLocationType.objects.all() 

532 serializer_class = StockSerializers.StockLocationTypeSerializer 

533 

534 def get_queryset(self): 

535 """Override the queryset method to include location count.""" 

536 queryset = super().get_queryset() 

537 queryset = StockSerializers.StockLocationTypeSerializer.annotate_queryset( 

538 queryset 

539 ) 

540 

541 return queryset 

542 

543 

544class StockFilter(FilterSet): 

545 """FilterSet for StockItem LIST API.""" 

546 

547 class Meta: 

548 """Metaclass options for this filterset.""" 

549 

550 model = StockItem 

551 

552 # Simple filter filters 

553 fields = [ 

554 'supplier_part', 

555 'belongs_to', 

556 'build', 

557 'customer', 

558 'consumed_by', 

559 'sales_order', 

560 'purchase_order', 

561 'tags__name', 

562 'tags__slug', 

563 ] 

564 

565 # Relationship filters 

566 manufacturer = rest_filters.ModelChoiceFilter( 

567 label='Manufacturer', 

568 queryset=Company.objects.all(), 

569 method='filter_manufacturer', 

570 ) 

571 

572 @extend_schema_field(OpenApiTypes.INT) 

573 def filter_manufacturer(self, queryset, name, company): 

574 """Filter by manufacturer.""" 

575 return queryset.filter( 

576 Q(supplier_part__manufacturer_part__manufacturer__is_manufacturer=True) 

577 & Q(supplier_part__manufacturer_part__manufacturer=company) 

578 ) 

579 

580 manufacturer_part = rest_filters.ModelChoiceFilter( 

581 label=_('Manufacturer Part'), 

582 queryset=ManufacturerPart.objects.all(), 

583 field_name='supplier_part__manufacturer_part', 

584 ) 

585 

586 supplier = rest_filters.ModelChoiceFilter( 

587 label=_('Supplier'), 

588 queryset=Company.objects.filter(is_supplier=True), 

589 field_name='supplier_part__supplier', 

590 ) 

591 

592 include_variants = rest_filters.BooleanFilter( 

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

594 ) 

595 

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

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

598 

599 Note: 

600 - This filter does nothing by itself, and requires the 'part' filter to be set. 

601 - Refer to the 'filter_part' method for more information. 

602 """ 

603 return queryset 

604 

605 part = rest_filters.ModelChoiceFilter( 

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

607 ) 

608 

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

610 """Filter StockItem list by provided Part instance. 

611 

612 Note: 

613 - If "part" is a variant, include all variants of the selected part 

614 - Otherwise, filter by the selected part 

615 """ 

616 include_variants = str2bool(self.data.get('include_variants', True)) 

617 

618 if include_variants: 

619 return queryset.filter(part__in=part.get_descendants(include_self=True)) 

620 else: 

621 return queryset.filter(part=part) 

622 

623 # Part name filters 

624 name = rest_filters.CharFilter( 

625 label=_('Part name (case insensitive)'), 

626 field_name='part__name', 

627 lookup_expr='iexact', 

628 ) 

629 

630 name_contains = rest_filters.CharFilter( 

631 label=_('Part name contains (case insensitive)'), 

632 field_name='part__name', 

633 lookup_expr='icontains', 

634 ) 

635 

636 name_regex = rest_filters.CharFilter( 

637 label=_('Part name (regex)'), field_name='part__name', lookup_expr='iregex' 

638 ) 

639 

640 # Part IPN filters 

641 IPN = rest_filters.CharFilter( 

642 label=_('Part IPN (case insensitive)'), 

643 field_name='part__IPN', 

644 lookup_expr='iexact', 

645 ) 

646 

647 IPN_contains = rest_filters.CharFilter( 

648 label=_('Part IPN contains (case insensitive)'), 

649 field_name='part__IPN', 

650 lookup_expr='icontains', 

651 ) 

652 

653 IPN_regex = rest_filters.CharFilter( 

654 label=_('Part IPN (regex)'), field_name='part__IPN', lookup_expr='iregex' 

655 ) 

656 

657 # Part attribute filters 

658 assembly = rest_filters.BooleanFilter( 

659 label=_('Assembly'), field_name='part__assembly' 

660 ) 

661 

662 active = rest_filters.BooleanFilter(label=_('Active'), field_name='part__active') 

663 salable = rest_filters.BooleanFilter(label=_('Salable'), field_name='part__salable') 

664 

665 min_stock = rest_filters.NumberFilter( 

666 label=_('Minimum stock'), field_name='quantity', lookup_expr='gte' 

667 ) 

668 

669 max_stock = rest_filters.NumberFilter( 

670 label=_('Maximum stock'), field_name='quantity', lookup_expr='lte' 

671 ) 

672 

673 status = rest_filters.NumberFilter(label=_('Status Code'), method='filter_status') 

674 

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

676 """Filter by integer status code. 

677 

678 Note: Also account for the possibility of a custom status code. 

679 """ 

680 q1 = Q(status=value, status_custom_key__isnull=True) 

681 q2 = Q(status_custom_key=value) 

682 

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

684 

685 allocated = rest_filters.BooleanFilter( 

686 label='Is Allocated', method='filter_allocated' 

687 ) 

688 

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

690 """Filter by whether or not the stock item is 'allocated'.""" 

691 if str2bool(value): 

692 # Filter StockItem with either build allocations or transfer order allocations or sales order allocations 

693 return queryset.filter( 

694 Q(sales_order_allocations__isnull=False) 

695 | Q(transfer_order_allocations__isnull=False) 

696 | Q(allocations__isnull=False) 

697 ).distinct() 

698 # Filter StockItem without build allocations or transfer order allocations or sales order allocations 

699 return queryset.filter( 

700 Q(sales_order_allocations__isnull=True) 

701 & Q(transfer_order_allocations__isnull=True) 

702 & Q(allocations__isnull=True) 

703 ) 

704 

705 expired = rest_filters.BooleanFilter(label='Expired', method='filter_expired') 

706 

707 def filter_expired(self, queryset, name, value): 

708 """Filter by whether or not the stock item has expired.""" 

709 if not common.settings.stock_expiry_enabled(): 

710 return queryset 

711 

712 if str2bool(value): 

713 return queryset.filter(StockItem.get_expired_filter()) 

714 return queryset.exclude(StockItem.get_expired_filter()) 

715 

716 external = rest_filters.BooleanFilter( 

717 label=_('External Location'), method='filter_external' 

718 ) 

719 

720 def filter_external(self, queryset, name, value): 

721 """Filter by whether or not the stock item is located in an external location.""" 

722 if str2bool(value): 

723 return queryset.filter(location__external=True) 

724 return queryset.exclude(location__external=True) 

725 

726 in_stock = rest_filters.BooleanFilter(label='In Stock', method='filter_in_stock') 

727 

728 def filter_in_stock(self, queryset, name, value): 

729 """Filter by if item is in stock.""" 

730 if str2bool(value): 730 ↛ 732line 730 didn't jump to line 732 because the condition on line 730 was always true

731 return queryset.filter(StockItem.IN_STOCK_FILTER) 

732 return queryset.exclude(StockItem.IN_STOCK_FILTER) 

733 

734 available = rest_filters.BooleanFilter(label='Available', method='filter_available') 

735 

736 def filter_available(self, queryset, name, value): 

737 """Filter by whether the StockItem is "available" or not. 

738 

739 Here, "available" means that the allocated quantity is less than the total quantity 

740 """ 

741 if str2bool(value): 

742 # The 'quantity' field is greater than the calculated 'allocated' field 

743 # Note that the item must also be "in stock" 

744 return queryset.filter(StockItem.IN_STOCK_FILTER).filter( 

745 Q(quantity__gt=F('allocated')) 

746 ) 

747 # The 'quantity' field is less than (or equal to) the calculated 'allocated' field 

748 return queryset.filter(Q(quantity__lte=F('allocated'))) 

749 

750 batch = rest_filters.CharFilter( 

751 label='Batch code filter (case insensitive)', lookup_expr='iexact' 

752 ) 

753 

754 batch_regex = rest_filters.CharFilter( 

755 label='Batch code filter (regex)', field_name='batch', lookup_expr='iregex' 

756 ) 

757 

758 is_building = rest_filters.BooleanFilter(label='In production') 

759 

760 # Serial number filtering 

761 serial_gte = rest_filters.NumberFilter( 

762 label='Serial number GTE', field_name='serial_int', lookup_expr='gte' 

763 ) 

764 serial_lte = rest_filters.NumberFilter( 

765 label='Serial number LTE', field_name='serial_int', lookup_expr='lte' 

766 ) 

767 

768 serial = rest_filters.CharFilter( 

769 label='Serial number', field_name='serial', lookup_expr='exact' 

770 ) 

771 

772 serialized = rest_filters.BooleanFilter( 

773 label='Has serial number', method='filter_serialized' 

774 ) 

775 

776 def filter_serialized(self, queryset, name, value): 

777 """Filter by whether the StockItem has a serial number (or not).""" 

778 q = Q(serial=None) | Q(serial='') 

779 

780 if str2bool(value): 

781 return queryset.exclude(q) 

782 

783 return queryset.filter(q).distinct() 

784 

785 has_batch = rest_filters.BooleanFilter( 

786 label='Has batch code', method='filter_has_batch' 

787 ) 

788 

789 def filter_has_batch(self, queryset, name, value): 

790 """Filter by whether the StockItem has a batch code (or not).""" 

791 q = Q(batch=None) | Q(batch='') 

792 

793 if str2bool(value): 

794 return queryset.exclude(q) 

795 

796 return queryset.filter(q).distinct() 

797 

798 tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked') 

799 

800 def filter_tracked(self, queryset, name, value): 

801 """Filter by whether this stock item is *tracked*. 

802 

803 Meaning either: 

804 - It has a serial number 

805 - It has a batch code 

806 """ 

807 q_batch = Q(batch=None) | Q(batch='') 

808 q_serial = Q(serial=None) | Q(serial='') 

809 

810 if str2bool(value): 

811 return queryset.exclude(q_batch & q_serial) 

812 

813 return queryset.filter(q_batch).filter(q_serial).distinct() 

814 

815 consumed = rest_filters.BooleanFilter( 

816 label=_('Consumed by Build Order'), method='filter_consumed' 

817 ) 

818 

819 def filter_consumed(self, queryset, name, value): 

820 """Filter by whether the stock item has been consumed by a build order.""" 

821 if str2bool(value): 

822 return queryset.filter(consumed_by__isnull=False) 

823 return queryset.filter(consumed_by__isnull=True) 

824 

825 installed = rest_filters.BooleanFilter( 

826 label=_('Installed in other stock item'), method='filter_installed' 

827 ) 

828 

829 def filter_installed(self, queryset, name, value): 

830 """Filter stock items by "belongs_to" field being empty.""" 

831 if str2bool(value): 

832 return queryset.exclude(belongs_to=None) 

833 return queryset.filter(belongs_to=None) 

834 

835 has_installed_items = rest_filters.BooleanFilter( 

836 label='Has installed items', method='filter_has_installed' 

837 ) 

838 

839 def filter_has_installed(self, queryset, name, value): 

840 """Filter stock items by "belongs_to" field being empty.""" 

841 if str2bool(value): 

842 return queryset.filter(installed_items__gt=0) 

843 return queryset.filter(installed_items=0) 

844 

845 has_child_items = rest_filters.BooleanFilter( 

846 label='Has child items', method='filter_has_child_items' 

847 ) 

848 

849 def filter_has_child_items(self, queryset, name, value): 

850 """Filter stock items by "belongs_to" field being empty.""" 

851 if str2bool(value): 

852 return queryset.filter(child_items__gt=0) 

853 return queryset.filter(child_items=0) 

854 

855 sent_to_customer = rest_filters.BooleanFilter( 

856 label='Sent to customer', method='filter_sent_to_customer' 

857 ) 

858 

859 def filter_sent_to_customer(self, queryset, name, value): 

860 """Filter by sent to customer.""" 

861 if str2bool(value): 

862 return queryset.exclude(customer=None) 

863 return queryset.filter(customer=None) 

864 

865 depleted = rest_filters.BooleanFilter(label='Depleted', method='filter_depleted') 

866 

867 def filter_depleted(self, queryset, name, value): 

868 """Filter by depleted items.""" 

869 if str2bool(value): 

870 return queryset.filter(quantity__lte=0) 

871 return queryset.exclude(quantity__lte=0) 

872 

873 has_purchase_price = rest_filters.BooleanFilter( 

874 label='Has purchase price', method='filter_has_purchase_price' 

875 ) 

876 

877 def filter_has_purchase_price(self, queryset, name, value): 

878 """Filter by having a purchase price.""" 

879 if str2bool(value): 

880 return queryset.exclude(purchase_price=None) 

881 return queryset.filter(purchase_price=None) 

882 

883 ancestor = rest_filters.ModelChoiceFilter( 

884 label='Ancestor', queryset=StockItem.objects.all(), method='filter_ancestor' 

885 ) 

886 

887 @extend_schema_field(OpenApiTypes.INT) 

888 def filter_ancestor(self, queryset, name, ancestor): 

889 """Filter based on ancestor stock item.""" 

890 return queryset.filter(parent__in=ancestor.get_descendants(include_self=True)) 

891 

892 category = rest_filters.ModelChoiceFilter( 

893 label=_('Category'), 

894 queryset=PartCategory.objects.all(), 

895 method='filter_category', 

896 ) 

897 

898 @extend_schema_field(OpenApiTypes.INT) 

899 def filter_category(self, queryset, name, category): 

900 """Filter based on part category.""" 

901 child_categories = category.get_descendants(include_self=True) 

902 

903 return queryset.filter(part__category__in=child_categories) 

904 

905 bom_item = rest_filters.ModelChoiceFilter( 

906 label=_('BOM Item'), queryset=BomItem.objects.all(), method='filter_bom_item' 

907 ) 

908 

909 @extend_schema_field(OpenApiTypes.INT) 

910 def filter_bom_item(self, queryset, name, bom_item): 

911 """Filter based on BOM item.""" 

912 return queryset.filter(bom_item.get_stock_filter()) 

913 

914 part_tree = rest_filters.ModelChoiceFilter( 

915 label=_('Part Tree'), queryset=Part.objects.all(), method='filter_part_tree' 

916 ) 

917 

918 @extend_schema_field(OpenApiTypes.INT) 

919 def filter_part_tree(self, queryset, name, part_tree): 

920 """Filter based on part tree.""" 

921 return queryset.filter(part__tree_id=part_tree.tree_id) 

922 

923 company = rest_filters.ModelChoiceFilter( 

924 label=_('Company'), queryset=Company.objects.all(), method='filter_company' 

925 ) 

926 

927 @extend_schema_field(OpenApiTypes.INT) 

928 def filter_company(self, queryset, name, company): 

929 """Filter by company (either manufacturer or supplier).""" 

930 return queryset.filter( 

931 Q(supplier_part__supplier=company) 

932 | Q(supplier_part__manufacturer_part__manufacturer=company) 

933 ).distinct() 

934 

935 created_before = InvenTreeDateFilter( 

936 label=_('Created before'), field_name='creation_date', lookup_expr='lt' 

937 ) 

938 

939 created_after = InvenTreeDateFilter( 

940 label=_('Created after'), field_name='creation_date', lookup_expr='gt' 

941 ) 

942 

943 updated_before = InvenTreeDateFilter( 

944 label=_('Updated before'), field_name='updated', lookup_expr='lt' 

945 ) 

946 

947 updated_after = InvenTreeDateFilter( 

948 label=_('Updated after'), field_name='updated', lookup_expr='gt' 

949 ) 

950 

951 stocktake_before = InvenTreeDateFilter( 

952 label=_('Stocktake Before'), field_name='stocktake_date', lookup_expr='lt' 

953 ) 

954 

955 stocktake_after = InvenTreeDateFilter( 

956 label=_('Stocktake After'), field_name='stocktake_date', lookup_expr='gt' 

957 ) 

958 

959 has_stocktake = rest_filters.BooleanFilter( 

960 label=_('Has Stocktake Date'), method='filter_has_stocktake' 

961 ) 

962 

963 def filter_has_stocktake(self, queryset, name, value): 

964 """Filter by whether or not the StockItem has a stocktake date.""" 

965 if str2bool(value): 

966 return queryset.exclude(stocktake_date=None) 

967 return queryset.filter(stocktake_date=None) 

968 

969 # Stock "expiry" filters 

970 expiry_before = InvenTreeDateFilter( 

971 label=_('Expiry date before'), field_name='expiry_date', lookup_expr='lt' 

972 ) 

973 

974 expiry_after = InvenTreeDateFilter( 

975 label=_('Expiry date after'), field_name='expiry_date', lookup_expr='gt' 

976 ) 

977 

978 stale = rest_filters.BooleanFilter(label=_('Stale'), method='filter_stale') 

979 

980 def filter_stale(self, queryset, name, value): 

981 """Filter by stale stock items.""" 

982 stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS') 

983 

984 if stale_days <= 0: 

985 # No filtering, does not make sense 

986 return queryset 

987 

988 stale_date = InvenTree.helpers.current_date() + timedelta(days=stale_days) 

989 stale_filter = ( 

990 StockItem.IN_STOCK_FILTER 

991 & ~Q(expiry_date=None) 

992 & Q(expiry_date__lt=stale_date) 

993 ) 

994 

995 if str2bool(value): 

996 return queryset.filter(stale_filter) 

997 else: 

998 return queryset.exclude(stale_filter) 

999 

1000 exclude_tree = rest_filters.NumberFilter( 

1001 method='filter_exclude_tree', 

1002 label=_('Exclude Tree'), 

1003 help_text=_( 

1004 'Provide a StockItem PK to exclude that item and all its descendants' 

1005 ), 

1006 ) 

1007 

1008 def filter_exclude_tree(self, queryset, name, value): 

1009 """Exclude a StockItem and all of its descendants from the queryset.""" 

1010 try: 

1011 root = StockItem.objects.get(pk=value) 

1012 pks_to_exclude = [ 

1013 item.pk for item in root.get_descendants(include_self=True) 

1014 ] 

1015 return queryset.exclude(pk__in=pks_to_exclude) 

1016 except (ValueError, StockItem.DoesNotExist): 

1017 # If the value is invalid or the object doesn't exist, do nothing. 

1018 return queryset 

1019 

1020 cascade = rest_filters.BooleanFilter( 

1021 method='filter_cascade', 

1022 label=_('Cascade Locations'), 

1023 help_text=_('If true, include items in child locations of the given location'), 

1024 ) 

1025 

1026 location = NumberOrNullFilter( 

1027 method='filter_location', 

1028 label=_('Location'), 

1029 help_text=_("Filter by numeric Location ID or the literal 'null'"), 

1030 ) 

1031 

1032 def filter_cascade(self, queryset, name, value): 

1033 """Dummy filter method for 'cascade'. 

1034 

1035 - Ensures 'cascade' appears in API documentation 

1036 - Does NOT actually filter the queryset directly 

1037 """ 

1038 return queryset 

1039 

1040 def filter_location(self, queryset, name, value): 

1041 """Filter for location that also applies cascade logic.""" 

1042 cascade = str2bool(self.data.get('cascade', True)) 

1043 

1044 if value == 'null': 

1045 if not cascade: 

1046 return queryset.filter(location=None) 

1047 return queryset 

1048 

1049 if not cascade: 

1050 return queryset.filter(location=value) 

1051 

1052 try: 

1053 loc_obj = StockLocation.objects.get(pk=value) 

1054 except StockLocation.DoesNotExist: 

1055 return queryset 

1056 

1057 children = loc_obj.getUniqueChildren() 

1058 return queryset.filter(location__in=children) 

1059 

1060 tags = common.filters.TagsFilter(label=_('Tags')) 

1061 

1062 

1063class StockApiMixin(SerializerContextMixin): 

1064 """Mixin class for StockItem API endpoints.""" 

1065 

1066 serializer_class = StockSerializers.StockItemSerializer 

1067 queryset = StockItem.objects.all() 

1068 

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

1070 """Annotate queryset.""" 

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

1072 queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset) 

1073 

1074 return queryset 

1075 

1076 def get_serializer_context(self): 

1077 """Extend serializer context.""" 

1078 ctx = super().get_serializer_context() 

1079 ctx['user'] = getattr(self.request, 'user', None) 

1080 

1081 return ctx 

1082 

1083 

1084class StockOutputOptions(OutputConfiguration): 

1085 """Output options for StockItem serializers.""" 

1086 

1087 OPTIONS = [ 

1088 InvenTreeOutputOption('part_detail', default=True), 

1089 InvenTreeOutputOption('path_detail'), 

1090 InvenTreeOutputOption('supplier_part_detail'), 

1091 InvenTreeOutputOption('location_detail'), 

1092 InvenTreeOutputOption('tests'), 

1093 ] 

1094 

1095 

1096class StockList( 

1097 DataExportViewMixin, 

1098 BulkUpdateMixin, 

1099 StockApiMixin, 

1100 OutputOptionsMixin, 

1101 ListCreateDestroyAPIView, 

1102): 

1103 """API endpoint for list view of Stock objects. 

1104 

1105 - GET: Return a list of all StockItem objects (with optional query filters) 

1106 - POST: Create a new StockItem 

1107 - DELETE: Delete multiple StockItem objects 

1108 """ 

1109 

1110 filterset_class = StockFilter 

1111 output_options = StockOutputOptions 

1112 

1113 def create(self, request, *args, **kwargs): 

1114 """Create a new StockItem object via the API. 

1115 

1116 We override the default 'create' implementation. 

1117 

1118 If a location is *not* specified, but the linked *part* has a default location, 

1119 we can pre-fill the location automatically. 

1120 """ 

1121 user = request.user 

1122 

1123 # Copy the request data, to side-step "mutability" issues 

1124 data = OrderedDict() 

1125 # Update with cleaned input data 

1126 data.update(self.clean_data(request.data)) 

1127 

1128 quantity = data.get('quantity', None) 

1129 

1130 if quantity is None: 

1131 raise ValidationError({'quantity': _('Quantity is required')}) 

1132 

1133 try: 

1134 part = Part.objects.get(pk=data.get('part', None)) 

1135 except (ValueError, Part.DoesNotExist): 

1136 raise ValidationError({'part': _('Valid part must be supplied')}) 

1137 

1138 location = data.get('location', None) 

1139 # Override location if not specified 

1140 if location is None and part.default_location: 

1141 data['location'] = part.default_location.pk 

1142 

1143 expiry_date = data.get('expiry_date', None) 

1144 

1145 # An expiry date was *not* specified - try to infer it! 

1146 if expiry_date is None and part.default_expiry > 0: 

1147 data['expiry_date'] = InvenTree.helpers.current_date() + timedelta( 

1148 days=part.default_expiry 

1149 ) 

1150 

1151 # Attempt to extract serial numbers from submitted data 

1152 serials = None 

1153 

1154 # Check if a set of serial numbers was provided 

1155 serial_numbers = data.pop('serial_numbers', '') 

1156 

1157 # Exclude 'serial' from submitted data 

1158 # We use 'serial_numbers' for item creation 

1159 data.pop('serial', None) 

1160 

1161 # Check if the supplier_part has a package size defined, which is not 1 

1162 if supplier_part_id := data.get('supplier_part', None): 

1163 try: 

1164 supplier_part = SupplierPart.objects.get(pk=supplier_part_id) 

1165 except Exception: 

1166 raise ValidationError({ 

1167 'supplier_part': _('The given supplier part does not exist') 

1168 }) 

1169 

1170 if supplier_part.base_quantity() != 1: 

1171 # Skip this check if pack size is 1 - makes no difference 

1172 # use_pack_size = True -> Multiply quantity by pack size 

1173 # use_pack_size = False -> Use quantity as is 

1174 if 'use_pack_size' not in data: 

1175 raise ValidationError({ 

1176 'use_pack_size': _( 

1177 'The supplier part has a pack size defined, but flag use_pack_size not set' 

1178 ) 

1179 }) 

1180 elif bool(data.get('use_pack_size')): 

1181 quantity = data['quantity'] = supplier_part.base_quantity(quantity) 

1182 

1183 # Divide purchase price by pack size, to save correct price per stock item 

1184 if ( 

1185 data.get('purchase_price') 

1186 and supplier_part.pack_quantity_native 

1187 ): 

1188 try: 

1189 data['purchase_price'] = float( 

1190 data['purchase_price'] 

1191 ) / float(supplier_part.pack_quantity_native) 

1192 except ValueError: # pragma: no cover 

1193 pass 

1194 

1195 # Now remove the flag from data, so that it doesn't interfere with saving 

1196 # Do this regardless of results above 

1197 data.pop('use_pack_size', None) 

1198 

1199 # Extract 'status' flag from data 

1200 status_raw = data.pop('status', None) 

1201 status_custom = data.pop('status_custom_key', None) 

1202 status_value = status_custom or status_raw 

1203 

1204 # Assign serial numbers for a trackable part 

1205 if serial_numbers: 

1206 if not part.trackable: 

1207 raise ValidationError({ 

1208 'serial_numbers': [ 

1209 _('Serial numbers cannot be supplied for a non-trackable part') 

1210 ] 

1211 }) 

1212 

1213 # If serial numbers are specified, check that they match! 

1214 try: 

1215 serials = extract_serial_numbers( 

1216 serial_numbers, quantity, part.get_latest_serial_number(), part=part 

1217 ) 

1218 

1219 # Determine if any of the specified serial numbers are invalid 

1220 # Note "invalid" means either they already exist, or do not pass custom rules 

1221 invalid = [] 

1222 errors = [] 

1223 

1224 try: 

1225 invalid = part.find_conflicting_serial_numbers(serials) 

1226 except DjangoValidationError as exc: 

1227 errors.append(exc.message) 

1228 

1229 if len(invalid) > 0: 

1230 msg = _('The following serial numbers already exist or are invalid') 

1231 msg += ' : ' 

1232 msg += ','.join([str(e) for e in invalid]) 

1233 

1234 errors.append(msg) 

1235 

1236 if len(errors) > 0: 

1237 raise ValidationError({'serial_numbers': errors}) 

1238 

1239 except DjangoValidationError as e: 

1240 raise ValidationError({ 

1241 'quantity': e.messages, 

1242 'serial_numbers': e.messages, 

1243 }) 

1244 

1245 if serials is not None: 

1246 """If the stock item is going to be serialized, set the quantity to 1.""" 

1247 data['quantity'] = 1 

1248 

1249 # De-serialize the provided data 

1250 serializer = self.get_serializer(data=data) 

1251 serializer.is_valid(raise_exception=True) 

1252 

1253 # Extract location information 

1254 location = serializer.validated_data.get('location', None) 

1255 

1256 with transaction.atomic(): 

1257 if serials: 

1258 # Create multiple serialized StockItem objects 

1259 items = StockItem._create_serial_numbers( 

1260 serials, **serializer.validated_data 

1261 ) 

1262 

1263 # Next, bulk-create stock tracking entries for the newly created items 

1264 tracking = [] 

1265 

1266 for item in items: 

1267 if status_value and not item.compare_status(status_value): 

1268 item.set_status(status_value) 

1269 item.save() 

1270 

1271 if entry := item.add_tracking_entry( 

1272 StockHistoryCode.CREATED, 

1273 user, 

1274 deltas={'status': status_value}, 

1275 location=location, 

1276 quantity=float(item.quantity), 

1277 commit=False, 

1278 ): 

1279 tracking.append(entry) 

1280 

1281 StockItemTracking.objects.bulk_create(tracking, batch_size=250) 

1282 

1283 # Annotate the stock items with part information 

1284 queryset = StockSerializers.StockItemSerializer.annotate_queryset(items) 

1285 

1286 response = StockSerializers.StockItemSerializer( 

1287 queryset, many=True, context=self.get_serializer_context() 

1288 ) 

1289 

1290 response_data = response.data 

1291 

1292 else: 

1293 # Create a single StockItem object 

1294 # Note: This automatically creates a tracking entry 

1295 item = StockItem(**serializer.validated_data) 

1296 

1297 if status_value and not item.compare_status(status_value): 

1298 item.set_status(status_value) 

1299 

1300 item.save(user=user) 

1301 item.refresh_from_db() 

1302 

1303 response_data = [ 

1304 StockSerializers.StockItemSerializer( 

1305 item, context=self.get_serializer_context() 

1306 ).data 

1307 ] 

1308 

1309 return Response( 

1310 response_data, 

1311 status=status.HTTP_201_CREATED, 

1312 headers=self.get_success_headers(serializer.data), 

1313 ) 

1314 

1315 filter_backends = SEARCH_ORDER_FILTER 

1316 

1317 ordering_field_aliases = { 

1318 'part': 'part__name', 

1319 'location': 'location__pathstring', 

1320 'IPN': 'part__IPN', 

1321 'SKU': 'supplier_part__SKU', 

1322 'MPN': 'supplier_part__manufacturer_part__MPN', 

1323 'stock': ['quantity', 'serial_int', 'serial'], 

1324 } 

1325 

1326 ordering_fields = [ 

1327 'batch', 

1328 'location', 

1329 'part', 

1330 'part__name', 

1331 'part__IPN', 

1332 'updated', 

1333 'purchase_price', 

1334 'creation_date', 

1335 'stocktake_date', 

1336 'expiry_date', 

1337 'packaging', 

1338 'quantity', 

1339 'stock', 

1340 'status', 

1341 'IPN', 

1342 'SKU', 

1343 'MPN', 

1344 ] 

1345 

1346 ordering = ['part__name', 'quantity', 'location'] 

1347 

1348 search_fields = [ 

1349 'serial', 

1350 'batch', 

1351 'location__name', 

1352 'part__name', 

1353 'part__IPN', 

1354 'part__description', 

1355 'supplier_part__SKU', 

1356 'supplier_part__supplier__name', 

1357 'supplier_part__manufacturer_part__MPN', 

1358 'supplier_part__manufacturer_part__manufacturer__name', 

1359 'tags__name', 

1360 'tags__slug', 

1361 ] 

1362 

1363 

1364class StockDetail(StockApiMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI): 

1365 """API detail endpoint for a single StockItem instance.""" 

1366 

1367 output_options = StockOutputOptions 

1368 

1369 

1370class StockItemSerialNumbers(RetrieveAPI): 

1371 """View extra serial number information for a given stock item. 

1372 

1373 Provides information on the "previous" and "next" stock items, 

1374 based on the serial number of the given stock item. 

1375 """ 

1376 

1377 queryset = StockItem.objects.all() 

1378 serializer_class = StockSerializers.StockItemSerialNumbersSerializer 

1379 

1380 

1381class StockItemTestResultMixin(SerializerContextMixin): 

1382 """Mixin class for the StockItemTestResult API endpoints.""" 

1383 

1384 queryset = StockItemTestResult.objects.all() 

1385 serializer_class = StockSerializers.StockItemTestResultSerializer 

1386 

1387 def get_serializer_context(self): 

1388 """Extend serializer context.""" 

1389 ctx = super().get_serializer_context() 

1390 ctx['request'] = self.request 

1391 return ctx 

1392 

1393 

1394class StockItemTestResultOutputOptions(OutputConfiguration): 

1395 """Output options for StockItemTestResult endpoint.""" 

1396 

1397 OPTIONS = [ 

1398 InvenTreeOutputOption(flag='user_detail'), 

1399 InvenTreeOutputOption(flag='template_detail'), 

1400 ] 

1401 

1402 

1403class StockItemTestResultDetail( 

1404 StockItemTestResultMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI 

1405): 

1406 """Detail endpoint for StockItemTestResult.""" 

1407 

1408 output_options = StockItemTestResultOutputOptions 

1409 

1410 

1411class StockItemTestResultFilter(FilterSet): 

1412 """API filter for the StockItemTestResult list.""" 

1413 

1414 class Meta: 

1415 """Metaclass options.""" 

1416 

1417 model = StockItemTestResult 

1418 

1419 # Simple filter fields 

1420 fields = ['user', 'template', 'result', 'value'] 

1421 

1422 build = rest_filters.ModelChoiceFilter( 

1423 label='Build', queryset=Build.objects.all(), field_name='stock_item__build' 

1424 ) 

1425 

1426 part = rest_filters.ModelChoiceFilter( 

1427 label='Part', queryset=Part.objects.all(), field_name='stock_item__part' 

1428 ) 

1429 

1430 required = rest_filters.BooleanFilter( 

1431 label='Required', field_name='template__required' 

1432 ) 

1433 

1434 enabled = rest_filters.BooleanFilter( 

1435 label='Enabled', field_name='template__enabled' 

1436 ) 

1437 

1438 test = rest_filters.CharFilter( 

1439 label='Test name (case insensitive)', method='filter_test_name' 

1440 ) 

1441 

1442 def filter_test_name(self, queryset, name, value): 

1443 """Filter by test name. 

1444 

1445 This method is provided for legacy support, 

1446 where the StockItemTestResult model had a "test" field. 

1447 Now the "test" name is stored against the PartTestTemplate model 

1448 """ 

1449 key = generateTestKey(value) 

1450 return queryset.filter(template__key=key) 

1451 

1452 include_installed = rest_filters.BooleanFilter( 

1453 method='filter_include_installed', 

1454 label=_('Include Installed'), 

1455 help_text=_( 

1456 'If true, include test results for items installed underneath the given stock item' 

1457 ), 

1458 ) 

1459 

1460 stock_item = rest_filters.NumberFilter( 

1461 method='filter_stock_item', 

1462 label=_('Stock Item'), 

1463 help_text=_('Filter by numeric Stock Item ID'), 

1464 ) 

1465 

1466 def filter_include_installed(self, queryset, name, value): 

1467 """Dummy filter method for 'include_installed'. 

1468 

1469 - Ensures 'include_installed' appears in API documentation 

1470 - Does NOT actually filter the queryset directly 

1471 - The actual logic is handled in filter_stock_item method 

1472 """ 

1473 return queryset 

1474 

1475 def filter_stock_item(self, queryset, name, value): 

1476 """Filter for stock_item that also applies include_installed logic.""" 

1477 include_installed = str2bool(self.data.get('include_installed', False)) 

1478 

1479 try: 

1480 item = StockItem.objects.get(pk=value) 

1481 

1482 except StockItem.DoesNotExist: 

1483 raise ValidationError({ 

1484 'stock_item': _('Stock item with ID {id} does not exist').format( 

1485 id=value 

1486 ) 

1487 }) 

1488 

1489 items = [item] 

1490 

1491 if include_installed: 

1492 # Include items which are installed "underneath" this item 

1493 # Note that this function is recursive! 

1494 installed_items = item.get_installed_items(cascade=True) 

1495 items += list(installed_items) 

1496 

1497 return queryset.filter(stock_item__in=items) 

1498 

1499 

1500class StockItemTestResultList( 

1501 BulkCreateMixin, 

1502 StockItemTestResultMixin, 

1503 OutputOptionsMixin, 

1504 ListCreateDestroyAPIView, 

1505): 

1506 """API endpoint for listing (and creating) a StockItemTestResult object.""" 

1507 

1508 filterset_class = StockItemTestResultFilter 

1509 filter_backends = SEARCH_ORDER_FILTER 

1510 output_options = StockItemTestResultOutputOptions 

1511 

1512 filterset_fields = ['user', 'template', 'result', 'value'] 

1513 ordering_fields = [ 

1514 'date', 

1515 'result', 

1516 'started_datetime', 

1517 'finished_datetime', 

1518 'test_station', 

1519 ] 

1520 

1521 ordering = 'date' 

1522 

1523 def perform_create(self, serializer): 

1524 """Create a new test result object. 

1525 

1526 Also, check if an attachment was uploaded alongside the test result, 

1527 and save it to the database if it were. 

1528 """ 

1529 serializer.save(user=self.request.user) 

1530 

1531 

1532class StockTrackingDetail(RetrieveAPI): 

1533 """Detail API endpoint for StockItemTracking model.""" 

1534 

1535 queryset = StockItemTracking.objects.all() 

1536 serializer_class = StockSerializers.StockTrackingSerializer 

1537 

1538 

1539class StockTrackingOutputOptions(OutputConfiguration): 

1540 """Output options for StockItemTracking endpoint.""" 

1541 

1542 OPTIONS = [ 

1543 InvenTreeOutputOption(flag='item_detail'), 

1544 InvenTreeOutputOption(flag='user_detail'), 

1545 ] 

1546 

1547 

1548class StockTrackingFilter(FilterSet): 

1549 """API filter options for the StockTrackingList endpoint.""" 

1550 

1551 class Meta: 

1552 """Metaclass options.""" 

1553 

1554 model = StockItemTracking 

1555 fields = ['item', 'user'] 

1556 

1557 include_variants = rest_filters.BooleanFilter( 

1558 label=_('Include Part Variants'), method='filter_include_variants' 

1559 ) 

1560 

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

1562 """Filter by whether or not to include part variants. 

1563 

1564 Note: 

1565 - This filter does nothing by itself, and is only used to modify the behavior of the 'part' filter. 

1566 - Refer to the 'filter_part' method for more information on how this works. 

1567 """ 

1568 return queryset 

1569 

1570 part = rest_filters.ModelChoiceFilter( 

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

1572 ) 

1573 

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

1575 """Filter StockTracking entries by the linked part. 

1576 

1577 Note: 

1578 - This filter behavior also takes into account the 'include_variants' filter, which determines whether or not to include part variants in the results. 

1579 """ 

1580 include_variants = str2bool(self.data.get('include_variants', False)) 

1581 

1582 if include_variants: 

1583 return queryset.filter(part__in=part.get_descendants(include_self=True)) 

1584 else: 

1585 return queryset.filter(part=part) 

1586 

1587 min_date = InvenTreeDateFilter( 

1588 label=_('Date after'), field_name='date', lookup_expr='gt' 

1589 ) 

1590 

1591 max_date = InvenTreeDateFilter( 

1592 label=_('Date before'), field_name='date', lookup_expr='lt' 

1593 ) 

1594 

1595 

1596class StockTrackingList( 

1597 SerializerContextMixin, DataExportViewMixin, OutputOptionsMixin, ListAPI 

1598): 

1599 """API endpoint for list view of StockItemTracking objects. 

1600 

1601 StockItemTracking objects are read-only 

1602 (they are created by internal model functionality) 

1603 

1604 - GET: Return list of StockItemTracking objects 

1605 """ 

1606 

1607 queryset = StockItemTracking.objects.all().prefetch_related('item', 'part') 

1608 serializer_class = StockSerializers.StockTrackingSerializer 

1609 filterset_class = StockTrackingFilter 

1610 output_options = StockTrackingOutputOptions 

1611 

1612 def get_delta_model_map(self) -> dict: 

1613 """Return a mapping of delta models to their respective models and serializers. 

1614 

1615 This is used to generate additional context information for the historical data, 

1616 with some attempt at caching so that we can reduce the number of database hits. 

1617 """ 

1618 return { 

1619 'part': (Part, PartBriefSerializer), 

1620 'location': (StockLocation, StockSerializers.LocationSerializer), 

1621 'customer': (Company, CompanySerializer), 

1622 'purchaseorder': (PurchaseOrder, PurchaseOrderSerializer), 

1623 'salesorder': (SalesOrder, SalesOrderSerializer), 

1624 'returnorder': (ReturnOrder, ReturnOrderSerializer), 

1625 'transferorder': (TransferOrder, TransferOrderSerializer), 

1626 'buildorder': (Build, BuildSerializer), 

1627 'item': (StockItem, StockSerializers.StockItemSerializer), 

1628 'stockitem': (StockItem, StockSerializers.StockItemSerializer), 

1629 } 

1630 

1631 def list(self, request, *args, **kwargs): 

1632 """List all stock tracking entries.""" 

1633 queryset = self.filter_queryset(self.get_queryset()) 

1634 

1635 page = self.paginate_queryset(queryset) 

1636 

1637 if page is not None: 

1638 serializer = self.get_serializer(page, many=True) 

1639 else: 

1640 serializer = self.get_serializer(queryset, many=True) 

1641 

1642 data = serializer.data 

1643 

1644 delta_models = self.get_delta_model_map() 

1645 

1646 # Construct a set of related models we need to lookup for later 

1647 related_model_lookups = {key: set() for key in delta_models} 

1648 

1649 # Run a first pass through the data to determine which related models we need to lookup 

1650 for item in data: 1650 ↛ 1651line 1650 didn't jump to line 1651 because the loop on line 1650 never started

1651 deltas = item['deltas'] or {} 

1652 

1653 for key in delta_models: 

1654 if key in deltas: 

1655 related_model_lookups[key].add(deltas[key]) 

1656 

1657 for key in delta_models: 

1658 model, serializer = delta_models[key] 

1659 

1660 # Fetch all related models in one go 

1661 related_models = model.objects.filter(pk__in=related_model_lookups[key]) 

1662 

1663 # Construct a mapping of pk -> serialized data 

1664 related_data = {obj.pk: serializer(obj).data for obj in related_models} 

1665 

1666 # Now, update the data with the serialized data 

1667 for item in data: 1667 ↛ 1668line 1667 didn't jump to line 1668 because the loop on line 1667 never started

1668 deltas = item['deltas'] or {} 

1669 

1670 if key in deltas: 

1671 item['deltas'][f'{key}_detail'] = related_data.get(deltas[key]) 

1672 

1673 if page is not None: 

1674 return self.get_paginated_response(data) 

1675 

1676 return Response(data) 

1677 

1678 filter_backends = SEARCH_ORDER_FILTER 

1679 

1680 ordering = '-date' 

1681 

1682 ordering_fields = ['date'] 

1683 

1684 search_fields = ['notes'] 

1685 

1686 

1687stock_api_urls = [ 

1688 path( 

1689 'location/', 

1690 include([ 

1691 path('tree/', StockLocationTree.as_view(), name='api-location-tree'), 

1692 # Stock location detail endpoints 

1693 path( 

1694 '<int:pk>/', 

1695 include([ 

1696 meta_path(StockLocation), 

1697 path('', StockLocationDetail.as_view(), name='api-location-detail'), 

1698 ]), 

1699 ), 

1700 path('', StockLocationList.as_view(), name='api-location-list'), 

1701 ]), 

1702 ), 

1703 # Stock location type endpoints 

1704 path( 

1705 'location-type/', 

1706 include([ 

1707 path( 

1708 '<int:pk>/', 

1709 include([ 

1710 meta_path(StockLocationType), 

1711 path( 

1712 '', 

1713 StockLocationTypeDetail.as_view(), 

1714 name='api-location-type-detail', 

1715 ), 

1716 ]), 

1717 ), 

1718 path('', StockLocationTypeList.as_view(), name='api-location-type-list'), 

1719 ]), 

1720 ), 

1721 # Endpoints for bulk stock adjustment actions 

1722 path('count/', StockCount.as_view(), name='api-stock-count'), 

1723 path('add/', StockAdd.as_view(), name='api-stock-add'), 

1724 path('remove/', StockRemove.as_view(), name='api-stock-remove'), 

1725 path('transfer/', StockTransfer.as_view(), name='api-stock-transfer'), 

1726 path('return/', StockReturn.as_view(), name='api-stock-return'), 

1727 path('assign/', StockAssign.as_view(), name='api-stock-assign'), 

1728 path('merge/', StockMerge.as_view(), name='api-stock-merge'), 

1729 path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'), 

1730 # StockItemTestResult API endpoints 

1731 path( 

1732 'test/', 

1733 include([ 

1734 path( 

1735 '<int:pk>/', 

1736 include([ 

1737 meta_path(StockItemTestResult), 

1738 path( 

1739 '', 

1740 StockItemTestResultDetail.as_view(), 

1741 name='api-stock-test-result-detail', 

1742 ), 

1743 ]), 

1744 ), 

1745 path( 

1746 '', StockItemTestResultList.as_view(), name='api-stock-test-result-list' 

1747 ), 

1748 ]), 

1749 ), 

1750 # StockItemTracking API endpoints 

1751 path( 

1752 'track/', 

1753 include([ 

1754 path( 

1755 '<int:pk>/', 

1756 StockTrackingDetail.as_view(), 

1757 name='api-stock-tracking-detail', 

1758 ), 

1759 # Stock tracking status code information 

1760 path( 

1761 'status/', 

1762 StatusView.as_view(), 

1763 {StatusView.MODEL_REF: StockHistoryCode}, 

1764 name='api-stock-tracking-status-codes', 

1765 ), 

1766 path('', StockTrackingList.as_view(), name='api-stock-tracking-list'), 

1767 ]), 

1768 ), 

1769 # Detail views for a single stock item 

1770 path( 

1771 '<int:pk>/', 

1772 include([ 

1773 path('convert/', StockItemConvert.as_view(), name='api-stock-item-convert'), 

1774 path('install/', StockItemInstall.as_view(), name='api-stock-item-install'), 

1775 meta_path(StockItem), 

1776 path( 

1777 'serialize/', 

1778 StockItemSerialize.as_view(), 

1779 name='api-stock-item-serialize', 

1780 ), 

1781 path( 

1782 'uninstall/', 

1783 StockItemUninstall.as_view(), 

1784 name='api-stock-item-uninstall', 

1785 ), 

1786 path( 

1787 'serial-numbers/', 

1788 StockItemSerialNumbers.as_view(), 

1789 name='api-stock-item-serial-numbers', 

1790 ), 

1791 path('', StockDetail.as_view(), name='api-stock-detail'), 

1792 ]), 

1793 ), 

1794 # Stock item status code information 

1795 path( 

1796 'status/', 

1797 StatusView.as_view(), 

1798 {StatusView.MODEL_REF: StockStatus}, 

1799 name='api-stock-status-codes', 

1800 ), 

1801 # Anything else 

1802 path('', StockList.as_view(), name='api-stock-list'), 

1803]