Coverage for src/backend/InvenTree/part/api.py: 70%

596 statements  

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

1"""Provides a JSON API for the Part app.""" 

2 

3from django.db.models import Count, F, Q 

4from django.urls import include, path 

5from django.utils.translation import gettext_lazy as _ 

6 

7import django_filters.rest_framework.filters as rest_filters 

8from django_filters.rest_framework import DjangoFilterBackend 

9from django_filters.rest_framework.filterset import FilterSet 

10from drf_spectacular.types import OpenApiTypes 

11from drf_spectacular.utils import extend_schema, extend_schema_field 

12from rest_framework import serializers 

13from rest_framework.response import Response 

14 

15import common.filters 

16import common.serializers 

17import part.tasks as part_tasks 

18from data_exporter.mixins import DataExportViewMixin 

19from InvenTree.api import ( 

20 BulkDeleteMixin, 

21 BulkUpdateMixin, 

22 ListCreateDestroyAPIView, 

23 ParameterListMixin, 

24 TreeMixin, 

25 meta_path, 

26) 

27from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration 

28from InvenTree.filters import ( 

29 ORDER_FILTER, 

30 SEARCH_ORDER_FILTER, 

31 InvenTreeDateFilter, 

32 InvenTreeSearchFilter, 

33 NumberOrNullFilter, 

34 NumericInFilter, 

35) 

36from InvenTree.helpers import str2bool 

37from InvenTree.mixins import ( 

38 CreateAPI, 

39 CustomRetrieveUpdateDestroyAPI, 

40 ListAPI, 

41 ListCreateAPI, 

42 OutputOptionsMixin, 

43 RetrieveAPI, 

44 RetrieveUpdateAPI, 

45 RetrieveUpdateDestroyAPI, 

46 SerializerContextMixin, 

47 UpdateAPI, 

48) 

49from InvenTree.tasks import offload_task 

50from stock.models import StockLocation 

51 

52from . import serializers as part_serializers 

53from .models import ( 

54 BomItem, 

55 BomItemSubstitute, 

56 Part, 

57 PartCategory, 

58 PartCategoryParameterTemplate, 

59 PartInternalPriceBreak, 

60 PartRelated, 

61 PartSellPriceBreak, 

62 PartStocktake, 

63 PartTestTemplate, 

64) 

65 

66 

67class CategoryMixin: 

68 """Mixin class for PartCategory endpoints.""" 

69 

70 serializer_class = part_serializers.CategorySerializer 

71 queryset = PartCategory.objects.all() 

72 

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

74 """Return an annotated queryset for the CategoryDetail endpoint.""" 

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

76 queryset = part_serializers.CategorySerializer.annotate_queryset(queryset) 

77 return queryset 

78 

79 

80class CategoryFilter(FilterSet): 

81 """Custom filterset class for the PartCategoryList endpoint.""" 

82 

83 class Meta: 

84 """Metaclass options for this filterset.""" 

85 

86 model = PartCategory 

87 fields = ['name', 'structural'] 

88 

89 starred = rest_filters.BooleanFilter( 

90 label=_('Starred'), 

91 method='filter_starred', 

92 help_text=_('Filter by starred categories'), 

93 ) 

94 

95 def filter_starred(self, queryset, name, value): 

96 """Filter by whether the PartCategory is starred by the current user.""" 

97 user = self.request.user 

98 

99 starred_categories = [ 

100 star.category.pk for star in user.starred_categories.all() 

101 ] 

102 

103 if str2bool(value): 

104 return queryset.filter(pk__in=starred_categories) 

105 

106 return queryset.exclude(pk__in=starred_categories) 

107 

108 depth = rest_filters.NumberFilter( 

109 label=_('Depth'), method='filter_depth', help_text=_('Filter by category depth') 

110 ) 

111 

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

113 """Filter by the "depth" of the PartCategory. 

114 

115 - This filter is used to limit the depth of the category tree 

116 - If the "parent" filter is also provided, the depth is calculated from the parent category 

117 """ 

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

119 

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

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

122 queryset = queryset.filter(level__lte=value) 

123 

124 return queryset 

125 

126 top_level = rest_filters.BooleanFilter( 

127 label=_('Top Level'), 

128 method='filter_top_level', 

129 help_text=_('Filter by top-level categories'), 

130 ) 

131 

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

133 """Filter by top-level categories.""" 

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

135 

136 if value and not cascade: 

137 return queryset.filter(parent=None) 

138 

139 return queryset 

140 

141 cascade = rest_filters.BooleanFilter( 

142 label=_('Cascade'), 

143 method='filter_cascade', 

144 help_text=_('Include sub-categories in filtered results'), 

145 ) 

146 

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

148 """Filter by whether to include sub-categories in the filtered results. 

149 

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

151 """ 

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

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

154 

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

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

157 # If "cascade" is False, only return top-level categories 

158 queryset = queryset.filter(parent=None) 

159 

160 return queryset 

161 

162 parent = rest_filters.ModelChoiceFilter( 

163 queryset=PartCategory.objects.all(), 

164 label=_('Parent'), 

165 method='filter_parent', 

166 help_text=_('Filter by parent category'), 

167 ) 

168 

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

170 """Filter by parent category. 

171 

172 Note that the filtering behaviour here varies, 

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

174 

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

176 """ 

177 parent = value 

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

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

180 

181 if cascade: 

182 # Return recursive subcategories 

183 queryset = queryset.filter( 

184 parent__in=parent.get_descendants(include_self=True) 

185 ) 

186 else: 

187 # Return only direct children 

188 queryset = queryset.filter(parent=parent) 

189 

190 if depth is not None: 

191 # Filter by depth from parent 

192 depth = int(depth) 

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

194 

195 return queryset 

196 

197 exclude_tree = rest_filters.ModelChoiceFilter( 

198 queryset=PartCategory.objects.all(), 

199 label=_('Exclude Tree'), 

200 method='filter_exclude_tree', 

201 help_text=_('Exclude sub-categories under the specified category'), 

202 ) 

203 

204 @extend_schema_field(OpenApiTypes.INT) 

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

206 """Exclude all sub-categories under the specified category.""" 

207 # Exclude the specified category 

208 queryset = queryset.exclude(pk=value.pk) 

209 

210 # Exclude any sub-categories also 

211 queryset = queryset.exclude(parent__in=value.get_descendants(include_self=True)) 

212 

213 return queryset 

214 

215 

216class CategoryOutputOption(OutputConfiguration): 

217 """Output option for PartCategory endpoints.""" 

218 

219 OPTIONS = [InvenTreeOutputOption(flag='path_detail')] 

220 

221 

222class CategoryList( 

223 CategoryMixin, 

224 BulkUpdateMixin, 

225 DataExportViewMixin, 

226 OutputOptionsMixin, 

227 ListCreateAPI, 

228): 

229 """API endpoint for accessing a list of PartCategory objects. 

230 

231 - GET: Return a list of PartCategory objects 

232 - POST: Create a new PartCategory object 

233 """ 

234 

235 filterset_class = CategoryFilter 

236 

237 filter_backends = SEARCH_ORDER_FILTER 

238 

239 output_options = CategoryOutputOption 

240 

241 ordering_fields = ['name', 'pathstring', 'level', 'tree_id', 'lft', 'part_count'] 

242 

243 # Use hierarchical ordering by default 

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

245 

246 search_fields = ['name', 'description', 'pathstring'] 

247 

248 

249class CategoryDetail(CategoryMixin, OutputOptionsMixin, CustomRetrieveUpdateDestroyAPI): 

250 """API endpoint for detail view of a single PartCategory object.""" 

251 

252 output_options = CategoryOutputOption 

253 

254 def update(self, request, *args, **kwargs): 

255 """Perform 'update' function and mark this part as 'starred' (or not).""" 

256 # Clean up input data 

257 data = self.clean_data(request.data) 

258 response = super().update(request, *args, **kwargs) 

259 

260 if 'starred' in data: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true

261 starred = str2bool(data.get('starred', False)) 

262 

263 self.get_object().set_starred(request.user, starred, include_parents=False) 

264 

265 return response 

266 

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

268 """Delete a Part category instance via the API.""" 

269 serializer = part_serializers.CategoryDeleteSerializer(data=request.data) 

270 serializer.is_valid(raise_exception=True) 

271 

272 delete_parts = str2bool(serializer.validated_data.get('delete_parts', False)) 

273 delete_child_categories = str2bool( 

274 serializer.validated_data.get('delete_child_categories', False) 

275 ) 

276 

277 return super().destroy( 

278 request, 

279 *args, 

280 **{ 

281 **kwargs, 

282 'delete_parts': delete_parts, 

283 'delete_child_categories': delete_child_categories, 

284 }, 

285 ) 

286 

287 

288class CategoryTreeFilter(FilterSet): 

289 """Custom filterset class for the CategoryTree endpoint.""" 

290 

291 class Meta: 

292 """Metaclass options for this filterset.""" 

293 

294 model = PartCategory 

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

296 

297 max_level = rest_filters.NumberFilter( 

298 label=_('Max Level'), 

299 method='filter_max_level', 

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

301 ) 

302 

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

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

305 return queryset.filter(level__lte=value) 

306 

307 

308class CategoryTree(TreeMixin, ListAPI): 

309 """API endpoint for accessing a list of PartCategory objects ready for rendering a tree.""" 

310 

311 model_class = PartCategory 

312 queryset = PartCategory.objects.all() 

313 serializer_class = part_serializers.CategoryTreeSerializer 

314 filterset_class = CategoryTreeFilter 

315 

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

317 """Return an annotated queryset for the CategoryTree endpoint.""" 

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

319 queryset = part_serializers.CategoryTreeSerializer.annotate_queryset(queryset) 

320 return queryset 

321 

322 

323class CategoryParameterList(DataExportViewMixin, OutputOptionsMixin, ListCreateAPI): 

324 """API endpoint for accessing a list of PartCategoryParameterTemplate objects. 

325 

326 - GET: Return a list of PartCategoryParameterTemplate objects 

327 """ 

328 

329 queryset = PartCategoryParameterTemplate.objects.all() 

330 serializer_class = part_serializers.CategoryParameterTemplateSerializer 

331 

332 def get_queryset(self): 

333 """Custom filtering. 

334 

335 Rules: 

336 - Allow filtering by "null" parent to retrieve all categories parameter templates 

337 - Allow filtering by category 

338 - Allow traversing all parent categories 

339 """ 

340 queryset = super().get_queryset() 

341 

342 params = self.request.query_params 

343 

344 category = params.get('category', None) 

345 

346 if category is not None: 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true

347 try: 

348 category = PartCategory.objects.get(pk=category) 

349 

350 fetch_parent = str2bool(params.get('fetch_parent', True)) 

351 

352 if fetch_parent: 

353 parents = category.get_ancestors(include_self=True) 

354 queryset = queryset.filter(category__in=[cat.pk for cat in parents]) 

355 else: 

356 queryset = queryset.filter(category=category) 

357 

358 except (ValueError, PartCategory.DoesNotExist): 

359 pass 

360 

361 return queryset 

362 

363 

364class CategoryParameterDetail(RetrieveUpdateDestroyAPI): 

365 """Detail endpoint for the PartCategoryParameterTemplate model.""" 

366 

367 queryset = PartCategoryParameterTemplate.objects.all() 

368 serializer_class = part_serializers.CategoryParameterTemplateSerializer 

369 

370 

371class PartSalePriceDetail(RetrieveUpdateDestroyAPI): 

372 """Detail endpoint for PartSellPriceBreak model.""" 

373 

374 queryset = PartSellPriceBreak.objects.all() 

375 serializer_class = part_serializers.PartSalePriceSerializer 

376 

377 

378class PartSalePriceList(DataExportViewMixin, ListCreateAPI): 

379 """API endpoint for list view of PartSalePriceBreak model.""" 

380 

381 queryset = PartSellPriceBreak.objects.all() 

382 serializer_class = part_serializers.PartSalePriceSerializer 

383 

384 filter_backends = SEARCH_ORDER_FILTER 

385 filterset_fields = ['part'] 

386 ordering_fields = ['quantity', 'price'] 

387 ordering = 'quantity' 

388 

389 

390class PartInternalPriceDetail(RetrieveUpdateDestroyAPI): 

391 """Detail endpoint for PartInternalPriceBreak model.""" 

392 

393 queryset = PartInternalPriceBreak.objects.all() 

394 serializer_class = part_serializers.PartInternalPriceSerializer 

395 

396 

397class PartInternalPriceList(DataExportViewMixin, ListCreateAPI): 

398 """API endpoint for list view of PartInternalPriceBreak model.""" 

399 

400 queryset = PartInternalPriceBreak.objects.all() 

401 serializer_class = part_serializers.PartInternalPriceSerializer 

402 permission_required = 'roles.sales_order.show' 

403 

404 filter_backends = SEARCH_ORDER_FILTER 

405 filterset_fields = ['part'] 

406 ordering_fields = ['quantity', 'price'] 

407 ordering = 'quantity' 

408 

409 

410class PartTestTemplateFilter(FilterSet): 

411 """Custom filterset class for the PartTestTemplateList endpoint.""" 

412 

413 class Meta: 

414 """Metaclass options for this filterset.""" 

415 

416 model = PartTestTemplate 

417 fields = ['enabled', 'key', 'required', 'requires_attachment', 'requires_value'] 

418 

419 part = rest_filters.ModelChoiceFilter( 

420 queryset=Part.objects.filter(testable=True), 

421 label='Part', 

422 field_name='part', 

423 method='filter_part', 

424 ) 

425 

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

427 """Filter by the 'part' field. 

428 

429 Note: If the 'include_inherited' query parameter is set, 

430 we also include any parts "above" the specified part. 

431 """ 

432 include_inherited = str2bool( 

433 self.request.query_params.get('include_inherited', True) 

434 ) 

435 

436 if include_inherited: 

437 return queryset.filter(part__in=part.get_ancestors(include_self=True)) 

438 else: 

439 return queryset.filter(part=part) 

440 

441 has_results = rest_filters.BooleanFilter( 

442 label=_('Has Results'), method='filter_has_results' 

443 ) 

444 

445 def filter_has_results(self, queryset, name, value): 

446 """Filter by whether the PartTestTemplate has any associated test results.""" 

447 if str2bool(value): 

448 return queryset.exclude(results=0) 

449 return queryset.filter(results=0) 

450 

451 

452class PartTestTemplateMixin: 

453 """Mixin class for the PartTestTemplate API endpoints.""" 

454 

455 queryset = PartTestTemplate.objects.all() 

456 serializer_class = part_serializers.PartTestTemplateSerializer 

457 

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

459 """Return an annotated queryset for the PartTestTemplateDetail endpoints.""" 

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

461 queryset = part_serializers.PartTestTemplateSerializer.annotate_queryset( 

462 queryset 

463 ) 

464 return queryset 

465 

466 

467class PartTestTemplateDetail(PartTestTemplateMixin, RetrieveUpdateDestroyAPI): 

468 """Detail endpoint for PartTestTemplate model.""" 

469 

470 

471class PartTestTemplateList(PartTestTemplateMixin, DataExportViewMixin, ListCreateAPI): 

472 """API endpoint for listing (and creating) a PartTestTemplate.""" 

473 

474 filterset_class = PartTestTemplateFilter 

475 

476 filter_backends = SEARCH_ORDER_FILTER 

477 

478 search_fields = ['test_name', 'description'] 

479 

480 ordering_fields = [ 

481 'enabled', 

482 'required', 

483 'requires_value', 

484 'requires_attachment', 

485 'results', 

486 'test_name', 

487 ] 

488 

489 ordering = 'test_name' 

490 

491 

492class PartThumbs(ListAPI): 

493 """API endpoint for retrieving information on available Part thumbnails.""" 

494 

495 queryset = Part.objects.all() 

496 serializer_class = part_serializers.PartThumbSerializer 

497 

498 def get_queryset(self): 

499 """Return a queryset which excludes any parts without images.""" 

500 queryset = super().get_queryset() 

501 

502 # Get all Parts which have an associated image 

503 queryset = queryset.exclude(image='') 

504 

505 return queryset 

506 

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

508 """Serialize the available Part images. 

509 

510 - Images may be used for multiple parts! 

511 """ 

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

513 

514 # Return the most popular parts first 

515 data = ( 

516 queryset.values('image').annotate(count=Count('image')).order_by('-count') 

517 ) 

518 

519 page = self.paginate_queryset(data) 

520 

521 if page is not None: 

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

523 else: 

524 serializer = self.get_serializer(data, many=True) 

525 

526 data = serializer.data 

527 

528 if page is not None: 

529 return self.get_paginated_response(data) 

530 else: 

531 return Response(data) 

532 

533 filter_backends = [InvenTreeSearchFilter] 

534 

535 search_fields = [ 

536 'name', 

537 'description', 

538 'IPN', 

539 'revision', 

540 'keywords', 

541 'category__name', 

542 ] 

543 

544 

545class PartThumbsUpdate(RetrieveUpdateAPI): 

546 """API endpoint for updating Part thumbnails.""" 

547 

548 queryset = Part.objects.all() 

549 serializer_class = part_serializers.PartThumbSerializerUpdate 

550 

551 filter_backends = [DjangoFilterBackend] 

552 

553 

554class PartRequirements(RetrieveAPI): 

555 """API endpoint detailing 'requirements' information for a particular part. 

556 

557 This endpoint returns information on upcoming requirements for: 

558 

559 - Sales Orders 

560 - Build Orders 

561 - Total requirements 

562 - How many of this part can be assembled with available stock 

563 

564 As this data is somewhat complex to calculate, is it not included in the default API 

565 """ 

566 

567 queryset = Part.objects.all() 

568 serializer_class = part_serializers.PartRequirementsSerializer 

569 

570 

571class PartPricingDetail(RetrieveUpdateAPI): 

572 """API endpoint for viewing part pricing data.""" 

573 

574 serializer_class = part_serializers.PartPricingSerializer 

575 queryset = Part.objects.all().select_related('pricing_data') 

576 

577 def get_object(self): 

578 """Return the PartPricing object associated with the linked Part.""" 

579 part = super().get_object() 

580 return part.pricing 

581 

582 def _get_serializer(self, *args, **kwargs): 

583 """Return a part pricing serializer object.""" 

584 part = self.get_object() 

585 kwargs['instance'] = part.pricing 

586 

587 return self.serializer_class(**kwargs) 

588 

589 

590class PartSerialNumberDetail(RetrieveAPI): 

591 """API endpoint for returning extra serial number information about a particular part.""" 

592 

593 queryset = Part.objects.all() 

594 serializer_class = part_serializers.PartSerialNumberSerializer 

595 

596 

597class PartCopyBOM(CreateAPI): 

598 """API endpoint for duplicating a BOM.""" 

599 

600 queryset = Part.objects.all() 

601 serializer_class = part_serializers.PartCopyBOMSerializer 

602 

603 def get_serializer_context(self): 

604 """Add custom information to the serializer context for this endpoint.""" 

605 ctx = super().get_serializer_context() 

606 

607 try: 

608 ctx['part'] = Part.objects.get(pk=self.kwargs.get('pk', None)) 

609 except Exception: 

610 pass 

611 

612 return ctx 

613 

614 

615class PartValidateBOM(RetrieveUpdateAPI): 

616 """API endpoint for 'validating' the BOM for a given Part.""" 

617 

618 queryset = Part.objects.all() 

619 serializer_class = part_serializers.PartBomValidateSerializer 

620 role_required = 'bom' 

621 

622 @extend_schema( 

623 responses={ 

624 200: common.serializers.TaskDetailSerializer, 

625 404: common.serializers.TaskDetailSerializer, 

626 } 

627 ) 

628 def update(self, request, *args, **kwargs): 

629 """Validate the referenced BomItem instance. 

630 

631 As this task is offloaded to the background worker, 

632 we return information about the background task which is performing the validation. 

633 """ 

634 part = self.get_object() 

635 

636 partial = kwargs.pop('partial', False) 

637 

638 # Clean up input data before using it 

639 data = self.clean_data(request.data) 

640 

641 serializer = self.get_serializer(part, data=data, partial=partial) 

642 serializer.is_valid(raise_exception=True) 

643 

644 valid = str2bool(serializer.validated_data.get('valid', False)) 

645 

646 # BOM validation may take some time, so we offload it to a background task 

647 task_id = offload_task( 

648 part_tasks.validate_bom, 

649 part.pk, 

650 valid, 

651 user_id=request.user.pk if request and request.user else None, 

652 group='part', 

653 ) 

654 

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

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

657 

658 

659class PartFilter(FilterSet): 

660 """Custom filters for the PartList endpoint. 

661 

662 Uses the django_filters extension framework 

663 """ 

664 

665 class Meta: 

666 """Metaclass options for this filter set.""" 

667 

668 model = Part 

669 fields = ['revision_of'] 

670 

671 is_variant = rest_filters.BooleanFilter( 

672 label=_('Is Variant'), method='filter_is_variant' 

673 ) 

674 

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

676 """Filter by whether the Part is a variant or not.""" 

677 return queryset.filter(variant_of__isnull=not str2bool(value)) 

678 

679 is_revision = rest_filters.BooleanFilter( 

680 label=_('Is Revision'), method='filter_is_revision' 

681 ) 

682 

683 def filter_is_revision(self, queryset, name, value): 

684 """Filter by whether the Part is a revision or not.""" 

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

686 return queryset.exclude(revision_of=None) 

687 return queryset.filter(revision_of=None) 

688 

689 has_revisions = rest_filters.BooleanFilter( 

690 label=_('Has Revisions'), method='filter_has_revisions' 

691 ) 

692 

693 def filter_has_revisions(self, queryset, name, value): 

694 """Filter by whether the Part has any revisions or not.""" 

695 if str2bool(value): 

696 return queryset.exclude(revision_count=0) 

697 return queryset.filter(revision_count=0) 

698 

699 has_units = rest_filters.BooleanFilter(label='Has units', method='filter_has_units') 

700 

701 def filter_has_units(self, queryset, name, value): 

702 """Filter by whether the Part has units or not.""" 

703 if str2bool(value): 

704 return queryset.exclude(Q(units=None) | Q(units='')) 

705 

706 return queryset.filter(Q(units=None) | Q(units='')).distinct() 

707 

708 # Filter by parts which have (or not) an IPN value 

709 has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn') 

710 

711 def filter_has_ipn(self, queryset, name, value): 

712 """Filter by whether the Part has an IPN (internal part number) or not.""" 

713 if str2bool(value): 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true

714 return queryset.exclude(IPN='').exclude(IPN=None) 

715 return queryset.filter(Q(IPN='') | Q(IPN=None)).distinct() 

716 

717 # Regex filter for name 

718 name_regex = rest_filters.CharFilter( 

719 label='Filter by name (regex)', field_name='name', lookup_expr='iregex' 

720 ) 

721 

722 # Exact match for IPN 

723 IPN = rest_filters.CharFilter( 

724 label='Filter by exact IPN (internal part number)', 

725 field_name='IPN', 

726 lookup_expr='iexact', 

727 ) 

728 

729 # Regex match for IPN 

730 IPN_regex = rest_filters.CharFilter( 

731 label='Filter by regex on IPN (internal part number)', 

732 field_name='IPN', 

733 lookup_expr='iregex', 

734 ) 

735 

736 # low_stock filter 

737 low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock') 

738 

739 def filter_low_stock(self, queryset, name, value): 

740 """Filter by "low stock" status.""" 

741 if str2bool(value): 

742 # Ignore any parts which do not have a specified 'minimum_stock' level 

743 # Filter items which have an 'in_stock' level lower than 'minimum_stock' 

744 return queryset.exclude(minimum_stock=0).filter( 

745 Q(total_in_stock__lt=F('minimum_stock')) 

746 ) 

747 # Filter items which have an 'in_stock' level higher than 'minimum_stock' 

748 return queryset.filter(Q(total_in_stock__gte=F('minimum_stock'))) 

749 

750 high_stock = rest_filters.BooleanFilter( 

751 label='High stock', method='filter_high_stock' 

752 ) 

753 

754 def filter_high_stock(self, queryset, name, value): 

755 """Filter by "high stock" status.""" 

756 if str2bool(value): 

757 # Ignore any parts which do not have a specified 'maximum_stock' level 

758 # Filter items which have an 'in_stock' level higher than 'maximum_stock' 

759 return queryset.exclude(maximum_stock=0).filter( 

760 Q(total_in_stock__gt=F('maximum_stock')) 

761 ) 

762 # Filter items which have an 'in_stock' level lower than 'maximum_stock' 

763 return queryset.filter( 

764 Q(total_in_stock__lte=F('maximum_stock')) | Q(maximum_stock=0) 

765 ).distinct() 

766 

767 # has_stock filter 

768 has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock') 

769 

770 def filter_has_stock(self, queryset, name, value): 

771 """Filter by whether the Part has any stock.""" 

772 if str2bool(value): 

773 return queryset.filter(Q(in_stock__gt=0)) 

774 return queryset.filter(Q(in_stock__lte=0)) 

775 

776 # unallocated_stock filter 

777 unallocated_stock = rest_filters.BooleanFilter( 

778 label='Unallocated stock', method='filter_unallocated_stock' 

779 ) 

780 

781 def filter_unallocated_stock(self, queryset, name, value): 

782 """Filter by whether the Part has unallocated stock.""" 

783 if str2bool(value): 

784 return queryset.filter(Q(unallocated_stock__gt=0)) 

785 return queryset.filter(Q(unallocated_stock__lte=0)) 

786 

787 convert_from = rest_filters.ModelChoiceFilter( 

788 label='Can convert from', 

789 queryset=Part.objects.all(), 

790 method='filter_convert_from', 

791 ) 

792 

793 @extend_schema_field(OpenApiTypes.INT) 

794 def filter_convert_from(self, queryset, name, part): 

795 """Limit the queryset to valid conversion options for the specified part.""" 

796 conversion_options = part.get_conversion_options() 

797 

798 queryset = queryset.filter(pk__in=conversion_options) 

799 

800 return queryset 

801 

802 exclude_tree = rest_filters.ModelChoiceFilter( 

803 label='Exclude Part tree', 

804 queryset=Part.objects.all(), 

805 method='filter_exclude_tree', 

806 ) 

807 

808 @extend_schema_field(OpenApiTypes.INT) 

809 def filter_exclude_tree(self, queryset, name, part): 

810 """Exclude all parts and variants 'down' from the specified part from the queryset.""" 

811 children = part.get_descendants(include_self=True) 

812 

813 return queryset.exclude(id__in=children) 

814 

815 ancestor = rest_filters.ModelChoiceFilter( 

816 label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor' 

817 ) 

818 

819 @extend_schema_field(OpenApiTypes.INT) 

820 def filter_ancestor(self, queryset, name, part): 

821 """Limit queryset to descendants of the specified ancestor part.""" 

822 descendants = part.get_descendants(include_self=False) 

823 return queryset.filter(id__in=descendants) 

824 

825 variant_of = rest_filters.ModelChoiceFilter( 

826 label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of' 

827 ) 

828 

829 def filter_variant_of(self, queryset, name, part): 

830 """Limit queryset to direct children (variants) of the specified part.""" 

831 return queryset.filter(id__in=part.get_children()) 

832 

833 in_bom_for = rest_filters.ModelChoiceFilter( 

834 label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom' 

835 ) 

836 

837 @extend_schema_field(OpenApiTypes.INT) 

838 def filter_in_bom(self, queryset, name, part): 

839 """Limit queryset to parts in the BOM for the specified part.""" 

840 bom_parts = part.get_parts_in_bom() 

841 return queryset.filter(id__in=[p.pk for p in bom_parts]) 

842 

843 has_pricing = rest_filters.BooleanFilter( 

844 label='Has Pricing', method='filter_has_pricing' 

845 ) 

846 

847 def filter_has_pricing(self, queryset, name, value): 

848 """Filter the queryset based on whether pricing information is available for the sub_part.""" 

849 q_a = Q(pricing_data=None) 

850 q_b = Q(pricing_data__overall_min=None, pricing_data__overall_max=None) 

851 

852 if str2bool(value): 

853 return queryset.exclude(q_a | q_b) 

854 

855 return queryset.filter(q_a | q_b).distinct() 

856 

857 stock_to_build = rest_filters.BooleanFilter( 

858 label='Required for Build Order', method='filter_stock_to_build' 

859 ) 

860 

861 def filter_stock_to_build(self, queryset, name, value): 

862 """Filter the queryset based on whether part stock is required for a pending BuildOrder.""" 

863 if str2bool(value): 

864 # Return parts which are required for a build order, but have not yet been allocated 

865 return queryset.filter( 

866 required_for_build_orders__gt=F('allocated_to_build_orders') 

867 ) 

868 # Return parts which are not required for a build order, or have already been allocated 

869 return queryset.filter( 

870 required_for_build_orders__lte=F('allocated_to_build_orders') 

871 ) 

872 

873 depleted_stock = rest_filters.BooleanFilter( 

874 label='Depleted Stock', method='filter_depleted_stock' 

875 ) 

876 

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

878 """Filter the queryset based on whether the part is fully depleted of stock.""" 

879 if str2bool(value): 

880 return queryset.filter(Q(in_stock=0) & ~Q(stock_item_count=0)) 

881 return queryset.exclude(Q(in_stock=0) & ~Q(stock_item_count=0)) 

882 

883 default_location = rest_filters.ModelChoiceFilter( 

884 label='Default Location', queryset=StockLocation.objects.all() 

885 ) 

886 

887 bom_valid = rest_filters.BooleanFilter( 

888 label=_('BOM Valid'), field_name='bom_validated' 

889 ) 

890 

891 starred = rest_filters.BooleanFilter(label='Starred', method='filter_starred') 

892 

893 def filter_starred(self, queryset, name, value): 

894 """Filter by whether the Part is 'starred' by the current user.""" 

895 if self.request.user.is_anonymous: 

896 return queryset 

897 

898 starred_parts = [ 

899 star.part.pk 

900 for star in self.request.user.starred_parts.all().prefetch_related('part') 

901 ] 

902 

903 if value: 

904 return queryset.filter(pk__in=starred_parts) 

905 else: 

906 return queryset.exclude(pk__in=starred_parts) 

907 

908 is_template = rest_filters.BooleanFilter() 

909 

910 assembly = rest_filters.BooleanFilter() 

911 

912 component = rest_filters.BooleanFilter() 

913 

914 trackable = rest_filters.BooleanFilter() 

915 

916 testable = rest_filters.BooleanFilter() 

917 

918 purchaseable = rest_filters.BooleanFilter() 

919 

920 salable = rest_filters.BooleanFilter() 

921 

922 active = rest_filters.BooleanFilter() 

923 

924 locked = rest_filters.BooleanFilter() 

925 

926 virtual = rest_filters.BooleanFilter() 

927 

928 tags = common.filters.TagsFilter() 

929 

930 # Created date filters 

931 created_before = InvenTreeDateFilter( 

932 label='Updated before', field_name='creation_date', lookup_expr='lt' 

933 ) 

934 created_after = InvenTreeDateFilter( 

935 label='Updated after', field_name='creation_date', lookup_expr='gt' 

936 ) 

937 

938 exclude_id = NumericInFilter( 

939 field_name='id', 

940 lookup_expr='in', 

941 exclude=True, 

942 help_text='Exclude parts with these IDs (comma-separated)', 

943 ) 

944 

945 related = rest_filters.NumberFilter( 

946 method='filter_related_parts', help_text='Show parts related to this part ID' 

947 ) 

948 

949 exclude_related = rest_filters.NumberFilter( 

950 method='filter_exclude_related_parts', 

951 help_text='Exclude parts related to this part ID', 

952 ) 

953 

954 def filter_related_parts(self, queryset, name, value): 

955 """Filter parts related to the specified part ID.""" 

956 if not value: 

957 return queryset 

958 

959 try: 

960 related_part = Part.objects.get(pk=value) 

961 part_ids = self._get_related_part_ids(related_part) 

962 return queryset.filter(pk__in=list(part_ids)) 

963 except (ValueError, Part.DoesNotExist): 

964 return queryset.none() 

965 

966 def filter_exclude_related_parts(self, queryset, name, value): 

967 """Exclude parts related to the specified part ID.""" 

968 if not value: 

969 return queryset 

970 

971 try: 

972 related_part = Part.objects.get(pk=value) 

973 part_ids = self._get_related_part_ids(related_part) 

974 return queryset.exclude(pk__in=list(part_ids)) 

975 except (ValueError, Part.DoesNotExist): 

976 return queryset 

977 

978 def _get_related_part_ids(self, related_part): 

979 """Return a set of part IDs which are related to the specified part.""" 

980 part_ids = set() 

981 pk = related_part.pk 

982 

983 relation_filter = Q(part_1=related_part) | Q(part_2=related_part) 

984 

985 for relation in PartRelated.objects.filter(relation_filter).distinct(): 

986 if relation.part_1.pk != pk: 

987 part_ids.add(relation.part_1.pk) 

988 if relation.part_2.pk != pk: 

989 part_ids.add(relation.part_2.pk) 

990 

991 return part_ids 

992 

993 cascade = rest_filters.BooleanFilter( 

994 method='filter_cascade', 

995 label=_('Cascade Categories'), 

996 help_text=_('If true, include items in child categories of the given category'), 

997 ) 

998 

999 category = NumberOrNullFilter( 

1000 method='filter_category', 

1001 label=_('Category'), 

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

1003 ) 

1004 

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

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

1007 

1008 - Ensures 'cascade' appears in API documentation 

1009 - Does NOT actually filter the queryset directly 

1010 """ 

1011 return queryset 

1012 

1013 def filter_category(self, queryset, name, value): 

1014 """Filter for category that also applies cascade logic.""" 

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

1016 

1017 if value == 'null': 

1018 if not cascade: 

1019 return queryset.filter(category=None) 

1020 return queryset 

1021 

1022 if not cascade: 

1023 return queryset.filter(category=value) 

1024 

1025 try: 

1026 category = PartCategory.objects.get(pk=value) 

1027 except PartCategory.DoesNotExist: 

1028 return queryset 

1029 

1030 children = category.getUniqueChildren() 

1031 return queryset.filter(category__in=children) 

1032 

1033 

1034class PartMixin(SerializerContextMixin): 

1035 """Mixin class for Part API endpoints.""" 

1036 

1037 serializer_class = part_serializers.PartSerializer 

1038 queryset = ( 

1039 Part.objects.all().select_related('pricing_data').prefetch_related('category') 

1040 ) 

1041 

1042 starred_parts = None 

1043 is_create = False 

1044 

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

1046 """Return an annotated queryset object for the PartDetail endpoint.""" 

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

1048 

1049 queryset = part_serializers.PartSerializer.annotate_queryset(queryset) 

1050 

1051 return queryset 

1052 

1053 def get_serializer(self, *args, **kwargs): 

1054 """Return a serializer instance for this endpoint.""" 

1055 # Indicate that we can create a new Part via this endpoint 

1056 kwargs['create'] = self.is_create 

1057 

1058 return super().get_serializer(*args, **kwargs) 

1059 

1060 

1061class PartOutputOptions(OutputConfiguration): 

1062 """Output options for Part endpoints.""" 

1063 

1064 OPTIONS = [ 

1065 InvenTreeOutputOption( 

1066 'parameters', description='Include part parameters in response' 

1067 ), 

1068 InvenTreeOutputOption('category_detail'), 

1069 InvenTreeOutputOption('location_detail'), 

1070 InvenTreeOutputOption('path_detail'), 

1071 InvenTreeOutputOption('price_breaks'), 

1072 InvenTreeOutputOption('tags'), 

1073 ] 

1074 

1075 

1076class PartList( 

1077 PartMixin, 

1078 BulkUpdateMixin, 

1079 ParameterListMixin, 

1080 DataExportViewMixin, 

1081 OutputOptionsMixin, 

1082 ListCreateAPI, 

1083): 

1084 """API endpoint for accessing a list of Part objects, or creating a new Part instance.""" 

1085 

1086 output_options = PartOutputOptions 

1087 filterset_class = PartFilter 

1088 is_create = True 

1089 

1090 filter_backends = SEARCH_ORDER_FILTER 

1091 

1092 ordering_fields = [ 

1093 'id', 

1094 'name', 

1095 'creation_date', 

1096 'IPN', 

1097 'in_stock', 

1098 'total_in_stock', 

1099 'unallocated_stock', 

1100 'category', 

1101 'default_location', 

1102 'units', 

1103 'pricing_min', 

1104 'pricing_max', 

1105 'pricing_updated', 

1106 'revision', 

1107 'revision_count', 

1108 ] 

1109 

1110 ordering_field_aliases = { 

1111 'pricing_min': 'pricing_data__overall_min', 

1112 'pricing_max': 'pricing_data__overall_max', 

1113 'pricing_updated': 'pricing_data__updated', 

1114 } 

1115 

1116 # Default ordering 

1117 ordering = 'name' 

1118 

1119 search_fields = [ 

1120 'name', 

1121 'description', 

1122 'IPN', 

1123 'revision', 

1124 'keywords', 

1125 'category__name', 

1126 'manufacturer_parts__MPN', 

1127 'supplier_parts__SKU', 

1128 'tags__name', 

1129 'tags__slug', 

1130 ] 

1131 

1132 

1133class PartDetail(PartMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI): 

1134 """API endpoint for detail view of a single Part object.""" 

1135 

1136 output_options = PartOutputOptions 

1137 

1138 def update(self, request, *args, **kwargs): 

1139 """Custom update functionality for Part instance. 

1140 

1141 - If the 'starred' field is provided, update the 'starred' status against current user 

1142 """ 

1143 # Clean input data 

1144 data = self.clean_data(request.data) 

1145 response = super().update(request, *args, **kwargs) 

1146 

1147 if 'starred' in data: 1147 ↛ 1148line 1147 didn't jump to line 1148 because the condition on line 1147 was never true

1148 starred = str2bool(data.get('starred', False)) 

1149 

1150 self.get_object().set_starred( 

1151 request.user, starred, include_variants=False, include_categories=False 

1152 ) 

1153 

1154 return response 

1155 

1156 

1157class PartRelatedFilter(FilterSet): 

1158 """FilterSet for PartRelated objects.""" 

1159 

1160 class Meta: 

1161 """Metaclass options.""" 

1162 

1163 model = PartRelated 

1164 fields = ['part_1', 'part_2'] 

1165 

1166 part = rest_filters.ModelChoiceFilter( 

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

1168 ) 

1169 

1170 @extend_schema_field(serializers.IntegerField(help_text=_('Part'))) 

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

1172 """Filter queryset to include only PartRelated objects which reference the specified part.""" 

1173 return queryset.filter(Q(part_1=part) | Q(part_2=part)).distinct() 

1174 

1175 

1176class PartRelatedMixin: 

1177 """Mixin class for PartRelated API endpoints.""" 

1178 

1179 queryset = PartRelated.objects.all() 

1180 serializer_class = part_serializers.PartRelationSerializer 

1181 

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

1183 """Return an annotated queryset for the PartRelatedDetail endpoint.""" 

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

1185 

1186 queryset = queryset.prefetch_related('part_1', 'part_2') 

1187 

1188 return queryset 

1189 

1190 

1191class PartRelatedList(PartRelatedMixin, ListCreateAPI): 

1192 """API endpoint for accessing a list of PartRelated objects.""" 

1193 

1194 filterset_class = PartRelatedFilter 

1195 filter_backends = SEARCH_ORDER_FILTER 

1196 

1197 search_fields = ['part_1__name', 'part_2__name'] 

1198 

1199 

1200class PartRelatedDetail(PartRelatedMixin, RetrieveUpdateDestroyAPI): 

1201 """API endpoint for accessing detail view of a PartRelated object.""" 

1202 

1203 

1204class PartStocktakeFilter(FilterSet): 

1205 """Custom filter for the PartStocktakeList endpoint.""" 

1206 

1207 class Meta: 

1208 """Metaclass options.""" 

1209 

1210 model = PartStocktake 

1211 fields = ['part'] 

1212 

1213 

1214class PartStocktakeMixin: 

1215 """Mixin class for PartStocktake API endpoints.""" 

1216 

1217 queryset = PartStocktake.objects.all().prefetch_related('part') 

1218 serializer_class = part_serializers.PartStocktakeSerializer 

1219 

1220 

1221class PartStocktakeList( 

1222 PartStocktakeMixin, DataExportViewMixin, BulkDeleteMixin, ListCreateAPI 

1223): 

1224 """API endpoint for listing part stocktake information.""" 

1225 

1226 filterset_class = PartStocktakeFilter 

1227 

1228 def get_serializer_context(self): 

1229 """Extend serializer context data.""" 

1230 context = super().get_serializer_context() 

1231 context['request'] = self.request 

1232 

1233 return context 

1234 

1235 filter_backends = ORDER_FILTER 

1236 

1237 ordering_fields = ['part', 'item_count', 'quantity', 'date', 'user', 'pk'] 

1238 

1239 # Reverse date ordering by default 

1240 ordering = '-pk' 

1241 

1242 

1243class PartStocktakeDetail(PartStocktakeMixin, RetrieveUpdateDestroyAPI): 

1244 """Detail API endpoint for a single PartStocktake instance. 

1245 

1246 Note: Only staff (admin) users can access this endpoint. 

1247 """ 

1248 

1249 

1250class PartStocktakeGenerate(CreateAPI): 

1251 """API endpoint for generating a PartStocktake instance.""" 

1252 

1253 queryset = PartStocktake.objects.all() 

1254 serializer_class = part_serializers.PartStocktakeGenerateSerializer 

1255 

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

1257 """Perform stocktake generation on POST request.""" 

1258 from common.models import DataOutput 

1259 from part.stocktake import perform_stocktake 

1260 

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

1262 serializer.is_valid(raise_exception=True) 

1263 

1264 data = serializer.validated_data 

1265 

1266 part = data.get('part', None) 

1267 category = data.get('category', None) 

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

1269 

1270 # Do we want to generate a report? 

1271 if data.get('generate_report', True): 

1272 report_output = DataOutput.objects.create( 

1273 user=request.user, output_type='stocktake' 

1274 ) 

1275 else: 

1276 report_output = None 

1277 

1278 # Offload the actual stocktake generation to a background task, as it may take some time to complete 

1279 offload_task( 

1280 perform_stocktake, 

1281 part_id=part.pk if part else None, 

1282 category_id=category.pk if category else None, 

1283 location_id=location.pk if location else None, 

1284 generate_entry=data.get('generate_entry', True), 

1285 report_output_id=report_output.pk if report_output else None, 

1286 group='stocktake', 

1287 ) 

1288 

1289 if report_output: 

1290 report_output.refresh_from_db() 

1291 

1292 result = { 

1293 'category': category, 

1294 'location': location, 

1295 'part': part, 

1296 'output': report_output, 

1297 } 

1298 

1299 output_serializer = part_serializers.PartStocktakeGenerateSerializer(result) 

1300 

1301 return Response(output_serializer.data) 

1302 

1303 

1304class BomFilter(FilterSet): 

1305 """Custom filters for the BOM list.""" 

1306 

1307 class Meta: 

1308 """Metaclass options.""" 

1309 

1310 model = BomItem 

1311 fields = ['optional', 'consumable', 'inherited', 'allow_variants', 'validated'] 

1312 

1313 # Filters for linked 'part' 

1314 part_active = rest_filters.BooleanFilter( 

1315 label=_('Assembly part is active'), field_name='part__active' 

1316 ) 

1317 

1318 part_trackable = rest_filters.BooleanFilter( 

1319 label=_('Assembly part is trackable'), field_name='part__trackable' 

1320 ) 

1321 

1322 part_testable = rest_filters.BooleanFilter( 

1323 label=_('Assembly part is testable'), field_name='part__testable' 

1324 ) 

1325 

1326 part_locked = rest_filters.BooleanFilter( 

1327 label=_('Assembly part is locked'), field_name='part__locked' 

1328 ) 

1329 

1330 # Filters for linked 'sub_part' 

1331 sub_part_active = rest_filters.BooleanFilter( 

1332 label=_('Component part is active'), field_name='sub_part__active' 

1333 ) 

1334 

1335 sub_part_trackable = rest_filters.BooleanFilter( 

1336 label=_('Component part is trackable'), field_name='sub_part__trackable' 

1337 ) 

1338 

1339 sub_part_testable = rest_filters.BooleanFilter( 

1340 label=_('Component part is testable'), field_name='sub_part__testable' 

1341 ) 

1342 

1343 sub_part_assembly = rest_filters.BooleanFilter( 

1344 label=_('Component part is an assembly'), field_name='sub_part__assembly' 

1345 ) 

1346 

1347 sub_part_virtual = rest_filters.BooleanFilter( 

1348 label=_('Component part is virtual'), field_name='sub_part__virtual' 

1349 ) 

1350 

1351 available_stock = rest_filters.BooleanFilter( 

1352 label=_('Has available stock'), method='filter_available_stock' 

1353 ) 

1354 

1355 def filter_available_stock(self, queryset, name, value): 

1356 """Filter the queryset based on whether each line item has any available stock.""" 

1357 if str2bool(value): 

1358 return queryset.filter(available_stock__gt=0) 

1359 return queryset.filter(available_stock=0) 

1360 

1361 on_order = rest_filters.BooleanFilter(label='On order', method='filter_on_order') 

1362 

1363 def filter_on_order(self, queryset, name, value): 

1364 """Filter the queryset based on whether each line item has any stock on order.""" 

1365 if str2bool(value): 

1366 return queryset.filter(on_order__gt=0) 

1367 return queryset.filter(on_order=0) 

1368 

1369 has_pricing = rest_filters.BooleanFilter( 

1370 label='Has Pricing', method='filter_has_pricing' 

1371 ) 

1372 

1373 def filter_has_pricing(self, queryset, name, value): 

1374 """Filter the queryset based on whether pricing information is available for the sub_part.""" 

1375 q_a = Q(sub_part__pricing_data=None) 

1376 q_b = Q( 

1377 sub_part__pricing_data__overall_min=None, 

1378 sub_part__pricing_data__overall_max=None, 

1379 ) 

1380 

1381 if str2bool(value): 

1382 return queryset.exclude(q_a | q_b) 

1383 

1384 return queryset.filter(q_a | q_b).distinct() 

1385 

1386 part = rest_filters.ModelChoiceFilter( 

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

1388 ) 

1389 

1390 @extend_schema_field(OpenApiTypes.INT) 

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

1392 """Filter the queryset based on the specified part.""" 

1393 return queryset.filter(part.get_bom_item_filter()) 

1394 

1395 category = rest_filters.ModelChoiceFilter( 

1396 queryset=PartCategory.objects.all(), 

1397 method='filter_category', 

1398 label=_('Category'), 

1399 ) 

1400 

1401 @extend_schema_field(OpenApiTypes.INT) 

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

1403 """Filter the queryset based on the specified PartCategory.""" 

1404 cats = category.get_descendants(include_self=True) 

1405 

1406 return queryset.filter(sub_part__category__in=cats) 

1407 

1408 uses = rest_filters.ModelChoiceFilter( 

1409 queryset=Part.objects.all(), method='filter_uses', label=_('Uses') 

1410 ) 

1411 

1412 @extend_schema_field(OpenApiTypes.INT) 

1413 def filter_uses(self, queryset, name, part): 

1414 """Filter the queryset based on the specified part.""" 

1415 return queryset.filter(part.get_used_in_bom_item_filter()) 

1416 

1417 

1418class BomMixin(SerializerContextMixin): 

1419 """Mixin class for BomItem API endpoints.""" 

1420 

1421 serializer_class = part_serializers.BomItemSerializer 

1422 queryset = BomItem.objects.all() 

1423 

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

1425 """Return the queryset object for this endpoint.""" 

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

1427 

1428 queryset = self.get_serializer_class().annotate_queryset(queryset) 

1429 

1430 return queryset 

1431 

1432 

1433class BomOutputOptions(OutputConfiguration): 

1434 """Output options for BOM endpoints.""" 

1435 

1436 OPTIONS = [ 

1437 InvenTreeOutputOption('can_build', default=True), 

1438 InvenTreeOutputOption('part_detail'), 

1439 InvenTreeOutputOption('sub_part_detail'), 

1440 InvenTreeOutputOption('substitutes'), 

1441 InvenTreeOutputOption('pricing'), 

1442 ] 

1443 

1444 

1445class BomList( 

1446 BomMixin, 

1447 BulkUpdateMixin, 

1448 DataExportViewMixin, 

1449 OutputOptionsMixin, 

1450 ListCreateDestroyAPIView, 

1451): 

1452 """API endpoint for accessing a list of BomItem objects. 

1453 

1454 - GET: Return list of BomItem objects 

1455 - POST: Create a new BomItem object 

1456 """ 

1457 

1458 output_options = BomOutputOptions 

1459 filterset_class = BomFilter 

1460 filter_backends = SEARCH_ORDER_FILTER 

1461 

1462 search_fields = [ 

1463 'reference', 

1464 'part__name', 

1465 'part__description', 

1466 'part__IPN', 

1467 'part__revision', 

1468 'part__keywords', 

1469 'sub_part__name', 

1470 'sub_part__description', 

1471 'sub_part__IPN', 

1472 'sub_part__revision', 

1473 'sub_part__keywords', 

1474 'sub_part__category__name', 

1475 ] 

1476 

1477 ordering_fields = [ 

1478 'can_build', 

1479 'category', 

1480 'quantity', 

1481 'setup_quantity', 

1482 'attrition', 

1483 'rounding_multiple', 

1484 'sub_part', 

1485 'IPN', 

1486 'available_stock', 

1487 'allow_variants', 

1488 'inherited', 

1489 'optional', 

1490 'consumable', 

1491 'reference', 

1492 'validated', 

1493 'pricing_min', 

1494 'pricing_max', 

1495 'pricing_min_total', 

1496 'pricing_max_total', 

1497 'pricing_updated', 

1498 ] 

1499 

1500 ordering_field_aliases = { 

1501 'category': 'sub_part__category__name', 

1502 'sub_part': 'sub_part__name', 

1503 'IPN': 'sub_part__IPN', 

1504 'pricing_min': 'sub_part__pricing_data__overall_min', 

1505 'pricing_max': 'sub_part__pricing_data__overall_max', 

1506 'pricing_updated': 'sub_part__pricing_data__updated', 

1507 } 

1508 

1509 def validate_delete(self, queryset, request) -> None: 

1510 """Ensure that there are no 'locked' items.""" 

1511 for bom_item in queryset: 

1512 # Note: Calling check_part_lock may raise a ValidationError 

1513 bom_item.check_part_lock(bom_item.part) 

1514 

1515 

1516class BomDetail(BomMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI): 

1517 """API endpoint for detail view of a single BomItem object.""" 

1518 

1519 output_options = BomOutputOptions 

1520 

1521 

1522class BomItemValidate(UpdateAPI): 

1523 """API endpoint for validating a BomItem.""" 

1524 

1525 class BomItemValidationSerializer(serializers.Serializer): 

1526 """Simple serializer for passing a single boolean field.""" 

1527 

1528 valid = serializers.BooleanField(default=False) 

1529 

1530 queryset = BomItem.objects.all() 

1531 serializer_class = BomItemValidationSerializer 

1532 

1533 def update(self, request, *args, **kwargs): 

1534 """Perform update request.""" 

1535 partial = kwargs.pop('partial', False) 

1536 

1537 # Clean up input data 

1538 data = self.clean_data(request.data) 

1539 valid = data.get('valid', False) 

1540 

1541 instance = self.get_object() 

1542 

1543 serializer = self.get_serializer(instance, data=data, partial=partial) 

1544 serializer.is_valid(raise_exception=True) 

1545 

1546 if isinstance(instance, BomItem): 

1547 instance.validate_hash(valid) 

1548 

1549 return Response(serializer.data) 

1550 

1551 

1552class BomItemSubstituteList(ListCreateAPI): 

1553 """API endpoint for accessing a list of BomItemSubstitute objects.""" 

1554 

1555 serializer_class = part_serializers.BomItemSubstituteSerializer 

1556 queryset = BomItemSubstitute.objects.all() 

1557 

1558 filter_backends = SEARCH_ORDER_FILTER 

1559 

1560 filterset_fields = ['part', 'bom_item'] 

1561 

1562 

1563class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): 

1564 """API endpoint for detail view of a single BomItemSubstitute object.""" 

1565 

1566 queryset = BomItemSubstitute.objects.all() 

1567 serializer_class = part_serializers.BomItemSubstituteSerializer 

1568 

1569 

1570part_api_urls = [ 

1571 # Base URL for PartCategory API endpoints 

1572 path( 

1573 'category/', 

1574 include([ 

1575 path('tree/', CategoryTree.as_view(), name='api-part-category-tree'), 

1576 path( 

1577 'parameters/', 

1578 include([ 

1579 path( 

1580 '<int:pk>/', 

1581 include([ 

1582 meta_path(PartCategoryParameterTemplate), 

1583 path( 

1584 '', 

1585 CategoryParameterDetail.as_view(), 

1586 name='api-part-category-parameter-detail', 

1587 ), 

1588 ]), 

1589 ), 

1590 path( 

1591 '', 

1592 CategoryParameterList.as_view(), 

1593 name='api-part-category-parameter-list', 

1594 ), 

1595 ]), 

1596 ), 

1597 # Category detail endpoints 

1598 path( 

1599 '<int:pk>/', 

1600 include([ 

1601 meta_path(PartCategory), 

1602 # PartCategory detail endpoint 

1603 path('', CategoryDetail.as_view(), name='api-part-category-detail'), 

1604 ]), 

1605 ), 

1606 path('', CategoryList.as_view(), name='api-part-category-list'), 

1607 ]), 

1608 ), 

1609 # Base URL for PartTestTemplate API endpoints 

1610 path( 

1611 'test-template/', 

1612 include([ 

1613 path( 

1614 '<int:pk>/', 

1615 include([ 

1616 meta_path(PartTestTemplate), 

1617 path( 

1618 '', 

1619 PartTestTemplateDetail.as_view(), 

1620 name='api-part-test-template-detail', 

1621 ), 

1622 ]), 

1623 ), 

1624 path( 

1625 '', PartTestTemplateList.as_view(), name='api-part-test-template-list' 

1626 ), 

1627 ]), 

1628 ), 

1629 # Base URL for part sale pricing 

1630 path( 

1631 'sale-price/', 

1632 include([ 

1633 path( 

1634 '<int:pk>/', 

1635 PartSalePriceDetail.as_view(), 

1636 name='api-part-sale-price-detail', 

1637 ), 

1638 path('', PartSalePriceList.as_view(), name='api-part-sale-price-list'), 

1639 ]), 

1640 ), 

1641 # Base URL for part internal pricing 

1642 path( 

1643 'internal-price/', 

1644 include([ 

1645 path( 

1646 '<int:pk>/', 

1647 PartInternalPriceDetail.as_view(), 

1648 name='api-part-internal-price-detail', 

1649 ), 

1650 path( 

1651 '', PartInternalPriceList.as_view(), name='api-part-internal-price-list' 

1652 ), 

1653 ]), 

1654 ), 

1655 # Base URL for PartRelated API endpoints 

1656 path( 

1657 'related/', 

1658 include([ 

1659 path( 

1660 '<int:pk>/', 

1661 include([ 

1662 meta_path(PartRelated), 

1663 path( 

1664 '', PartRelatedDetail.as_view(), name='api-part-related-detail' 

1665 ), 

1666 ]), 

1667 ), 

1668 path('', PartRelatedList.as_view(), name='api-part-related-list'), 

1669 ]), 

1670 ), 

1671 # Part stocktake data 

1672 path( 

1673 'stocktake/', 

1674 include([ 

1675 path( 

1676 '<int:pk>/', 

1677 PartStocktakeDetail.as_view(), 

1678 name='api-part-stocktake-detail', 

1679 ), 

1680 path( 

1681 'generate/', 

1682 PartStocktakeGenerate.as_view(), 

1683 name='api-part-stocktake-generate', 

1684 ), 

1685 path('', PartStocktakeList.as_view(), name='api-part-stocktake-list'), 

1686 ]), 

1687 ), 

1688 path( 

1689 'thumbs/', 

1690 include([ 

1691 path('', PartThumbs.as_view(), name='api-part-thumbs'), 

1692 path( 

1693 '<int:pk>/', PartThumbsUpdate.as_view(), name='api-part-thumbs-update' 

1694 ), 

1695 ]), 

1696 ), 

1697 path( 

1698 '<int:pk>/', 

1699 include([ 

1700 # Endpoint for extra serial number information 

1701 path( 

1702 'serial-numbers/', 

1703 PartSerialNumberDetail.as_view(), 

1704 name='api-part-serial-number-detail', 

1705 ), 

1706 path( 

1707 'requirements/', 

1708 PartRequirements.as_view(), 

1709 name='api-part-requirements', 

1710 ), 

1711 # Endpoint for duplicating a BOM for the specific Part 

1712 path('bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'), 

1713 # Endpoint for validating a BOM for the specific Part 

1714 path( 

1715 'bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate' 

1716 ), 

1717 # Part metadata 

1718 meta_path(Part), 

1719 # Part pricing 

1720 path('pricing/', PartPricingDetail.as_view(), name='api-part-pricing'), 

1721 # Part detail endpoint 

1722 path('', PartDetail.as_view(), name='api-part-detail'), 

1723 ]), 

1724 ), 

1725 path('', PartList.as_view(), name='api-part-list'), 

1726] 

1727 

1728bom_api_urls = [ 

1729 path( 

1730 'substitute/', 

1731 include([ 

1732 # Detail view 

1733 path( 

1734 '<int:pk>/', 

1735 include([ 

1736 meta_path(BomItemSubstitute), 

1737 path( 

1738 '', 

1739 BomItemSubstituteDetail.as_view(), 

1740 name='api-bom-substitute-detail', 

1741 ), 

1742 ]), 

1743 ), 

1744 # Catch all 

1745 path('', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'), 

1746 ]), 

1747 ), 

1748 # BOM Item Detail 

1749 path( 

1750 '<int:pk>/', 

1751 include([ 

1752 path('validate/', BomItemValidate.as_view(), name='api-bom-item-validate'), 

1753 meta_path(BomItem), 

1754 path('', BomDetail.as_view(), name='api-bom-item-detail'), 

1755 ]), 

1756 ), 

1757 # Catch-all 

1758 path('', BomList.as_view(), name='api-bom-list'), 

1759]