Coverage for src/backend/InvenTree/build/api.py: 77%

410 statements  

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

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

2 

3from __future__ import annotations 

4 

5from django.contrib.auth.models import User 

6from django.db.models import DecimalField, F, OuterRef, Q, Subquery, Sum 

7from django.db.models.functions import Coalesce 

8from django.urls import include, path 

9from django.utils.translation import gettext_lazy as _ 

10 

11import django_filters.rest_framework.filters as rest_filters 

12from django_filters.rest_framework.filterset import FilterSet 

13from drf_spectacular.utils import extend_schema, extend_schema_field 

14from rest_framework import serializers, status 

15from rest_framework.exceptions import NotFound, ValidationError 

16from rest_framework.response import Response 

17 

18import build.models as build_models 

19import build.serializers 

20import common.filters 

21import common.models 

22import common.serializers 

23import part.models as part_models 

24import stock.models as stock_models 

25import stock.serializers 

26from build.models import Build, BuildItem, BuildLine 

27from build.status_codes import BuildStatus, BuildStatusGroups 

28from data_exporter.mixins import DataExportViewMixin 

29from generic.states.api import StatusView 

30from InvenTree.api import BulkDeleteMixin, ParameterListMixin, meta_path 

31from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration 

32from InvenTree.filters import ( 

33 SEARCH_ORDER_FILTER, 

34 InvenTreeDateFilter, 

35 NumberOrNullFilter, 

36) 

37from InvenTree.helpers import str2bool 

38from InvenTree.mixins import ( 

39 CreateAPI, 

40 ListCreateAPI, 

41 OutputOptionsMixin, 

42 RetrieveUpdateDestroyAPI, 

43 SerializerContextMixin, 

44) 

45from users.models import Owner 

46 

47 

48class BuildFilter(FilterSet): 

49 """Custom filterset for BuildList API endpoint.""" 

50 

51 class Meta: 

52 """Metaclass options.""" 

53 

54 model = Build 

55 fields = ['issued_by', 'sales_order', 'external'] 

56 

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

58 

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

60 """Filter by integer status code. 

61 

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

63 """ 

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

65 q2 = Q(status_custom_key=value) 

66 

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

68 

69 active = rest_filters.BooleanFilter(label='Build is active', method='filter_active') 

70 

71 # 'outstanding' is an alias for 'active' here 

72 outstanding = rest_filters.BooleanFilter( 

73 label='Build is outstanding', method='filter_active' 

74 ) 

75 

76 def filter_active(self, queryset, name, value): 

77 """Filter the queryset to either include or exclude orders which are active.""" 

78 if str2bool(value): 

79 return queryset.filter(status__in=BuildStatusGroups.ACTIVE_CODES) 

80 return queryset.exclude(status__in=BuildStatusGroups.ACTIVE_CODES) 

81 

82 parent = rest_filters.ModelChoiceFilter( 

83 queryset=Build.objects.all(), label=_('Parent Build'), field_name='parent' 

84 ) 

85 

86 include_variants = rest_filters.BooleanFilter( 

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

88 ) 

89 

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

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

92 

93 Note: 

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

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

96 """ 

97 return queryset 

98 

99 part = rest_filters.ModelChoiceFilter( 

100 queryset=part_models.Part.objects.all(), 

101 field_name='part', 

102 method='filter_part', 

103 label=_('Part'), 

104 ) 

105 

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

107 """Filter by 'part' which is being built. 

108 

109 Note: 

110 - If "include_variants" is True, include all variants of the selected part. 

111 - Otherwise, just filter by the selected part. 

112 """ 

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

114 

115 if include_variants: 

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

117 else: 

118 return queryset.filter(part=part) 

119 

120 category = rest_filters.ModelChoiceFilter( 

121 queryset=part_models.PartCategory.objects.all(), 

122 method='filter_category', 

123 label=_('Category'), 

124 ) 

125 

126 @extend_schema_field(serializers.IntegerField(help_text=_('Category'))) 

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

128 """Filter by part category (including sub-categories).""" 

129 categories = category.get_descendants(include_self=True) 

130 return queryset.filter(part__category__in=categories) 

131 

132 ancestor = rest_filters.ModelChoiceFilter( 

133 queryset=Build.objects.all(), 

134 label=_('Ancestor Build'), 

135 method='filter_ancestor', 

136 ) 

137 

138 @extend_schema_field(serializers.IntegerField(help_text=_('Ancestor Build'))) 

139 def filter_ancestor(self, queryset, name, parent): 

140 """Filter by 'parent' build order.""" 

141 builds = parent.get_descendants(include_self=False) 

142 return queryset.filter(pk__in=[b.pk for b in builds]) 

143 

144 overdue = rest_filters.BooleanFilter( 

145 label='Build is overdue', method='filter_overdue' 

146 ) 

147 

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

149 """Filter the queryset to either include or exclude orders which are overdue.""" 

150 if str2bool(value): 

151 return queryset.filter(Build.get_overdue_filter()) 

152 return queryset.exclude(Build.get_overdue_filter()) 

153 

154 assigned_to_me = rest_filters.BooleanFilter( 

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

156 ) 

157 

158 def filter_assigned_to_me(self, queryset, name, value): 

159 """Filter by orders which are assigned to the current user.""" 

160 value = str2bool(value) 

161 

162 # Work out who "me" is! 

163 owners = Owner.get_owners_matching_user(self.request.user) 

164 

165 if value: 

166 return queryset.filter(responsible__in=owners) 

167 return queryset.exclude(responsible__in=owners) 

168 

169 assigned_to = rest_filters.ModelChoiceFilter( 

170 queryset=Owner.objects.all(), field_name='responsible', label=_('Assigned To') 

171 ) 

172 

173 def filter_responsible(self, queryset, name, owner): 

174 """Filter by orders which are assigned to the specified owner.""" 

175 owners = list(Owner.objects.filter(pk=owner)) 

176 

177 # if we query by a user, also find all ownerships through group memberships 

178 if len(owners) > 0 and owners[0].label() == 'user': 

179 owners = Owner.get_owners_matching_user( 

180 User.objects.get(pk=owners[0].owner_id) 

181 ) 

182 

183 return queryset.filter(responsible__in=owners) 

184 

185 # Exact match for reference 

186 reference = rest_filters.CharFilter( 

187 label='Filter by exact reference', field_name='reference', lookup_expr='iexact' 

188 ) 

189 

190 project_code = rest_filters.ModelChoiceFilter( 

191 queryset=common.models.ProjectCode.objects.all(), field_name='project_code' 

192 ) 

193 

194 has_project_code = rest_filters.BooleanFilter( 

195 label='has_project_code', method='filter_has_project_code' 

196 ) 

197 

198 def filter_has_project_code(self, queryset, name, value): 

199 """Filter by whether or not the order has a project code.""" 

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

201 return queryset.exclude(project_code=None) 

202 return queryset.filter(project_code=None) 

203 

204 created_before = InvenTreeDateFilter( 

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

206 ) 

207 

208 created_after = InvenTreeDateFilter( 

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

210 ) 

211 

212 has_start_date = rest_filters.BooleanFilter( 

213 label=_('Has start date'), method='filter_has_start_date' 

214 ) 

215 

216 def filter_has_start_date(self, queryset, name, value): 

217 """Filter by whether or not the order has a start date.""" 

218 return queryset.filter(start_date__isnull=not str2bool(value)) 

219 

220 start_date_before = InvenTreeDateFilter( 

221 label=_('Start date before'), field_name='start_date', lookup_expr='lt' 

222 ) 

223 

224 start_date_after = InvenTreeDateFilter( 

225 label=_('Start date after'), field_name='start_date', lookup_expr='gt' 

226 ) 

227 

228 has_target_date = rest_filters.BooleanFilter( 

229 label=_('Has target date'), method='filter_has_target_date' 

230 ) 

231 

232 def filter_has_target_date(self, queryset, name, value): 

233 """Filter by whether or not the order has a target date.""" 

234 return queryset.filter(target_date__isnull=not str2bool(value)) 

235 

236 target_date_before = InvenTreeDateFilter( 

237 label=_('Target date before'), field_name='target_date', lookup_expr='lt' 

238 ) 

239 

240 target_date_after = InvenTreeDateFilter( 

241 label=_('Target date after'), field_name='target_date', lookup_expr='gt' 

242 ) 

243 

244 completed_before = InvenTreeDateFilter( 

245 label=_('Completed before'), field_name='completion_date', lookup_expr='lt' 

246 ) 

247 

248 completed_after = InvenTreeDateFilter( 

249 label=_('Completed after'), field_name='completion_date', lookup_expr='gt' 

250 ) 

251 

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

253 

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

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

256 

257 This filter is used in combination with filter_max_date, 

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

259 

260 In particular, this is used in the UI for the calendar view. 

261 

262 So, we are interested in orders which are active *after* this date: 

263 

264 - creation_date is set *after* this date (but there is no start date) 

265 - start_date is set *after* this date 

266 - target_date is set *after* this date 

267 

268 """ 

269 q1 = Q(creation_date__gte=value, start_date__isnull=True) 

270 q2 = Q(start_date__gte=value) 

271 q3 = Q(target_date__gte=value) 

272 

273 return queryset.filter(q1 | q2 | q3).distinct() 

274 

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

276 

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

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

279 

280 This filter is used in combination with filter_min_date, 

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

282 

283 In particular, this is used in the UI for the calendar view. 

284 

285 So, we are interested in orders which are active *before* this date: 

286 

287 - creation_date is set *before* this date (but there is no start date) 

288 - start_date is set *before* this date 

289 - target_date is set *before* this date 

290 """ 

291 q1 = Q(creation_date__lte=value, start_date__isnull=True) 

292 q2 = Q(start_date__lte=value) 

293 q3 = Q(target_date__lte=value) 

294 

295 return queryset.filter(q1 | q2 | q3).distinct() 

296 

297 exclude_tree = rest_filters.ModelChoiceFilter( 

298 queryset=Build.objects.all(), 

299 method='filter_exclude_tree', 

300 label=_('Exclude Tree'), 

301 ) 

302 

303 @extend_schema_field(serializers.IntegerField(help_text=_('Exclude Tree'))) 

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

305 """Filter by excluding a tree of Build objects.""" 

306 queryset = queryset.exclude( 

307 pk__in=[bld.pk for bld in value.get_descendants(include_self=True)] 

308 ) 

309 

310 return queryset 

311 

312 tags = common.filters.TagsFilter() 

313 

314 

315class BuildMixin: 

316 """Mixin class for Build API endpoints.""" 

317 

318 queryset = Build.objects.all() 

319 serializer_class = build.serializers.BuildSerializer 

320 

321 def get_queryset(self): 

322 """Return the queryset for the Build API endpoints.""" 

323 queryset = super().get_queryset() 

324 

325 queryset = build.serializers.BuildSerializer.annotate_queryset(queryset) 

326 

327 return queryset 

328 

329 

330class BuildListOutputOptions(OutputConfiguration): 

331 """Output options for the BuildList endpoint.""" 

332 

333 OPTIONS = [InvenTreeOutputOption('part_detail', default=True)] 

334 

335 

336class BuildList( 

337 DataExportViewMixin, 

338 BuildMixin, 

339 OutputOptionsMixin, 

340 ParameterListMixin, 

341 ListCreateAPI, 

342): 

343 """API endpoint for accessing a list of Build objects. 

344 

345 - GET: Return list of objects (with filters) 

346 - POST: Create a new Build object 

347 """ 

348 

349 output_options = BuildListOutputOptions 

350 filterset_class = BuildFilter 

351 filter_backends = SEARCH_ORDER_FILTER 

352 ordering_fields = [ 

353 'reference', 

354 'part', 

355 'IPN', 

356 'part__name', 

357 'status', 

358 'creation_date', 

359 'start_date', 

360 'target_date', 

361 'completion_date', 

362 'quantity', 

363 'completed', 

364 'issued_by', 

365 'responsible', 

366 'project_code', 

367 'priority', 

368 'level', 

369 'external', 

370 ] 

371 ordering_field_aliases = { 

372 'reference': ['reference_int', 'reference'], 

373 'project_code': ['project_code__code'], 

374 'part': ['part__name'], 

375 'IPN': ['part__IPN'], 

376 } 

377 ordering = '-reference' 

378 search_fields = [ 

379 'reference', 

380 'title', 

381 'part__name', 

382 'part__IPN', 

383 'part__description', 

384 'project_code__code', 

385 'priority', 

386 ] 

387 

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

389 """Add extra context information to the endpoint serializer.""" 

390 kwargs['create'] = True 

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

392 

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

394 """Save user information on order creation.""" 

395 serializer = self.get_serializer(data=self.clean_data(request.data)) 

396 serializer.is_valid(raise_exception=True) 

397 

398 serializer.save(issued_by=request.user) 

399 

400 headers = self.get_success_headers(serializer.data) 

401 return Response( 

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

403 ) 

404 

405 

406class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI): 

407 """API endpoint for detail view of a Build object.""" 

408 

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

410 """Only allow deletion of a BuildOrder if the build status is CANCELLED.""" 

411 build = self.get_object() 

412 

413 if build.status != BuildStatus.CANCELLED: 

414 raise ValidationError({ 

415 'non_field_errors': [ 

416 _('Build must be cancelled before it can be deleted') 

417 ] 

418 }) 

419 

420 return super().destroy(request, *args, **kwargs) 

421 

422 

423class BuildUnallocate(CreateAPI): 

424 """API endpoint for unallocating stock items from a build order. 

425 

426 - The BuildOrder object is specified by the URL 

427 - "output" (StockItem) can optionally be specified 

428 - "bom_item" can optionally be specified 

429 """ 

430 

431 queryset = Build.objects.none() 

432 

433 serializer_class = build.serializers.BuildUnallocationSerializer 

434 

435 def get_serializer_context(self): 

436 """Add extra context information to the endpoint serializer.""" 

437 ctx = super().get_serializer_context() 

438 

439 try: 

440 ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) 

441 except Exception: 

442 pass 

443 

444 ctx['request'] = self.request 

445 

446 return ctx 

447 

448 

449class BuildLineFilter(FilterSet): 

450 """Custom filterset for the BuildLine API endpoint.""" 

451 

452 class Meta: 

453 """Meta information for the BuildLineFilter class.""" 

454 

455 model = BuildLine 

456 fields = ['build', 'bom_item'] 

457 

458 # Fields on related models 

459 consumable = rest_filters.BooleanFilter( 

460 label=_('Consumable'), field_name='bom_item__consumable' 

461 ) 

462 optional = rest_filters.BooleanFilter( 

463 label=_('Optional'), field_name='bom_item__optional' 

464 ) 

465 assembly = rest_filters.BooleanFilter( 

466 label=_('Assembly'), field_name='bom_item__sub_part__assembly' 

467 ) 

468 tracked = rest_filters.BooleanFilter( 

469 label=_('Tracked'), field_name='bom_item__sub_part__trackable' 

470 ) 

471 testable = rest_filters.BooleanFilter( 

472 label=_('Testable'), field_name='bom_item__sub_part__testable' 

473 ) 

474 

475 part = rest_filters.ModelChoiceFilter( 

476 queryset=part_models.Part.objects.all(), 

477 label=_('Part'), 

478 field_name='bom_item__sub_part', 

479 ) 

480 

481 order_outstanding = rest_filters.BooleanFilter( 

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

483 ) 

484 

485 def filter_order_outstanding(self, queryset, name, value): 

486 """Filter by whether the associated BuildOrder is 'outstanding'.""" 

487 if str2bool(value): 

488 return queryset.filter(build__status__in=BuildStatusGroups.ACTIVE_CODES) 

489 return queryset.exclude(build__status__in=BuildStatusGroups.ACTIVE_CODES) 

490 

491 allocated = rest_filters.BooleanFilter( 

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

493 ) 

494 

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

496 """Filter by whether each BuildLine is fully allocated.""" 

497 allocated_subquery = ( 

498 BuildItem.objects 

499 .filter(build_line=OuterRef('pk')) 

500 .values('build_line') 

501 .annotate(total=Sum('quantity')) 

502 .values('total') 

503 ) 

504 

505 queryset = queryset.alias( 

506 allocated_quantity=Coalesce( 

507 Subquery(allocated_subquery), 0, output_field=DecimalField() 

508 ) 

509 ) 

510 

511 if str2bool(value): 

512 return queryset.filter( 

513 allocated_quantity__gte=F('quantity') - F('consumed') 

514 ) 

515 return queryset.filter(allocated_quantity__lt=F('quantity') - F('consumed')) 

516 

517 consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed') 

518 

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

520 """Filter by whether each BuildLine is fully consumed.""" 

521 if str2bool(value): 

522 return queryset.filter(consumed__gte=F('quantity')) 

523 return queryset.filter(consumed__lt=F('quantity')) 

524 

525 available = rest_filters.BooleanFilter( 

526 label=_('Available'), method='filter_available' 

527 ) 

528 

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

530 """Filter by whether there is sufficient stock available for each BuildLine. 

531 

532 To determine this, we need to know: 

533 

534 - The quantity required for each BuildLine 

535 - The quantity available for each BuildLine (including variants and substitutes) 

536 - The quantity allocated for each BuildLine 

537 """ 

538 allocated_subquery = ( 

539 BuildItem.objects 

540 .filter(build_line=OuterRef('pk')) 

541 .values('build_line') 

542 .annotate(total=Sum('quantity')) 

543 .values('total') 

544 ) 

545 

546 queryset = queryset.alias( 

547 allocated_quantity=Coalesce( 

548 Subquery(allocated_subquery), 0, output_field=DecimalField() 

549 ) 

550 ) 

551 

552 # A query filter construct to determine the total quantity available for this BuildLine, 

553 # taking into account any stock which is already allocated or consumed 

554 available = ( 

555 F('allocated_quantity') 

556 + F('consumed') 

557 + F('available_stock') 

558 + F('available_substitute_stock') 

559 + F('available_variant_stock') 

560 ) 

561 

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

563 return queryset.filter(quantity__lte=available) 

564 

565 return queryset.filter(quantity__gt=available) 

566 

567 on_order = rest_filters.BooleanFilter(label=_('On Order'), method='filter_on_order') 

568 

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

570 """Filter by whether there is stock on order for each BuildLine.""" 

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

572 return queryset.filter(on_order__gt=0) 

573 else: 

574 return queryset.filter(on_order=0) 

575 

576 

577class BuildLineMixin(SerializerContextMixin): 

578 """Mixin class for BuildLine API endpoints.""" 

579 

580 queryset = BuildLine.objects.all() 

581 serializer_class = build.serializers.BuildLineSerializer 

582 

583 def get_source_build(self) -> Build: 

584 """Return the source Build object for the BuildLine queryset. 

585 

586 This source build is used to filter the available stock for each BuildLine. 

587 

588 - If this is a "detail" view, use the build associated with the line 

589 - If this is a "list" view, use the build associated with the request 

590 """ 

591 raise NotImplementedError( 

592 'get_source_build must be implemented in the child class' 

593 ) 

594 

595 def get_queryset(self): 

596 """Override queryset to select-related and annotate.""" 

597 queryset = super().get_queryset() 

598 

599 if not hasattr(self, 'source_build'): 

600 self.source_build = self.get_source_build() 

601 

602 source_build = self.source_build 

603 

604 return build.serializers.BuildLineSerializer.annotate_queryset( 

605 queryset, build=source_build 

606 ) 

607 

608 

609class BuildLineOutputOptions(OutputConfiguration): 

610 """Output options for BuildLine endpoint.""" 

611 

612 OPTIONS = [ 

613 InvenTreeOutputOption( 

614 'bom_item_detail', 

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

616 default=False, 

617 ), 

618 InvenTreeOutputOption( 

619 'assembly_detail', 

620 description='Include brief details of the assembly (parent part) related to the BOM item in this build line.', 

621 default=False, 

622 ), 

623 InvenTreeOutputOption( 

624 'part_detail', 

625 description='Include detailed information about the specific part being built or consumed in this build line.', 

626 default=False, 

627 ), 

628 InvenTreeOutputOption( 

629 'build_detail', 

630 description='Include detailed information about the associated build order.', 

631 default=False, 

632 ), 

633 InvenTreeOutputOption( 

634 'allocations', 

635 description='Include allocation details showing which stock items are allocated to this build line.', 

636 default=False, 

637 ), 

638 ] 

639 

640 

641class BuildLineList( 

642 BuildLineMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI 

643): 

644 """API endpoint for accessing a list of BuildLine objects.""" 

645 

646 filterset_class = BuildLineFilter 

647 filter_backends = SEARCH_ORDER_FILTER 

648 output_options = BuildLineOutputOptions 

649 ordering_fields = [ 

650 'part', 

651 'IPN', 

652 'allocated', 

653 'category', 

654 'consumed', 

655 'reference', 

656 'quantity', 

657 'consumable', 

658 'optional', 

659 'unit_quantity', 

660 'available_stock', 

661 'trackable', 

662 'allow_variants', 

663 'inherited', 

664 'on_order', 

665 'scheduled_to_build', 

666 ] 

667 

668 ordering_field_aliases = { 

669 'part': 'bom_item__sub_part__name', 

670 'IPN': 'bom_item__sub_part__IPN', 

671 'reference': 'bom_item__reference', 

672 'unit_quantity': 'bom_item__quantity', 

673 'category': 'bom_item__sub_part__category__name', 

674 'consumable': 'bom_item__consumable', 

675 'optional': 'bom_item__optional', 

676 'trackable': 'bom_item__sub_part__trackable', 

677 'allow_variants': 'bom_item__allow_variants', 

678 'inherited': 'bom_item__inherited', 

679 } 

680 

681 search_fields = [ 

682 'bom_item__sub_part__name', 

683 'bom_item__sub_part__IPN', 

684 'bom_item__sub_part__description', 

685 'bom_item__reference', 

686 ] 

687 

688 def get_source_build(self) -> Build | None: 

689 """Return the target build for the BuildLine queryset.""" 

690 source_build = None 

691 

692 try: 

693 build_id = self.request.query_params.get('build', None) 

694 if build_id: 

695 source_build = Build.objects.filter(pk=build_id).first() 

696 except (Build.DoesNotExist, AttributeError, ValueError): 

697 pass 

698 

699 return source_build 

700 

701 

702class BuildLineDetail(BuildLineMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI): 

703 """API endpoint for detail view of a BuildLine object.""" 

704 

705 output_options = BuildLineOutputOptions 

706 

707 def get_source_build(self) -> Build | None: 

708 """Return the target source location for the BuildLine queryset.""" 

709 return None 

710 

711 

712class BuildOrderContextMixin: 

713 """Mixin class which adds build order as serializer context variable.""" 

714 

715 def get_build(self): 

716 """Return the Build object associated with this API endpoint.""" 

717 try: 

718 return Build.objects.get(pk=self.kwargs.get('pk', None)) 

719 except (ValueError, Build.DoesNotExist): 

720 raise NotFound(_('Build not found')) 

721 

722 def get_serializer_context(self): 

723 """Add extra context information to the endpoint serializer.""" 

724 ctx = super().get_serializer_context() 

725 

726 ctx['request'] = self.request 

727 ctx['to_complete'] = True 

728 

729 try: 

730 ctx['build'] = self.get_build() 

731 except NotFound: 

732 pass 

733 

734 return ctx 

735 

736 

737@extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)}) 

738class BuildOutputCreate(BuildOrderContextMixin, CreateAPI): 

739 """API endpoint for creating new build output(s).""" 

740 

741 queryset = Build.objects.none() 

742 

743 serializer_class = build.serializers.BuildOutputCreateSerializer 

744 pagination_class = None 

745 

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

747 """Override the create method to handle the creation of build outputs.""" 

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

749 serializer.is_valid(raise_exception=True) 

750 

751 # Create the build output(s) 

752 outputs = serializer.save() 

753 

754 queryset = stock.serializers.StockItemSerializer.annotate_queryset(outputs) 

755 response = stock.serializers.StockItemSerializer(queryset, many=True) 

756 

757 # Return the created outputs 

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

759 

760 

761class BuildOutputScrap(BuildOrderContextMixin, CreateAPI): 

762 """API endpoint for scrapping build output(s).""" 

763 

764 queryset = Build.objects.none() 

765 serializer_class = build.serializers.BuildOutputScrapSerializer 

766 

767 def get_serializer_context(self): 

768 """Add extra context information to the endpoint serializer.""" 

769 ctx = super().get_serializer_context() 

770 ctx['to_complete'] = False 

771 return ctx 

772 

773 @extend_schema(responses={200: common.serializers.TaskDetailSerializer}) 

774 def post(self, *args, **kwargs): 

775 """Override POST to offload scrapping to the background worker.""" 

776 from build.tasks import scrap_build_outputs 

777 from InvenTree.tasks import offload_task 

778 

779 build = self.get_build() 

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

781 serializer.is_valid(raise_exception=True) 

782 data = serializer.validated_data 

783 

784 task_id = offload_task( 

785 scrap_build_outputs, 

786 build.pk, 

787 outputs=[ 

788 { 

789 'output_id': item['output'].pk, 

790 'quantity': float(item['quantity']) 

791 if item.get('quantity') is not None 

792 else None, 

793 } 

794 for item in data['outputs'] 

795 ], 

796 location_id=data['location'].pk, 

797 notes=data.get('notes', ''), 

798 discard_allocations=data.get('discard_allocations', False), 

799 user_id=self.request.user.pk, 

800 group='build', 

801 ) 

802 

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

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

805 

806 

807class BuildOutputComplete(BuildOrderContextMixin, CreateAPI): 

808 """API endpoint for completing build outputs.""" 

809 

810 queryset = Build.objects.none() 

811 serializer_class = build.serializers.BuildOutputCompleteSerializer 

812 

813 @extend_schema(responses={200: common.serializers.TaskDetailSerializer}) 

814 def post(self, *args, **kwargs): 

815 """Override POST to offload build output completion to the background worker.""" 

816 from build.tasks import complete_build_outputs 

817 from InvenTree.tasks import offload_task 

818 

819 build = self.get_build() 

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

821 serializer.is_valid(raise_exception=True) 

822 data = serializer.validated_data 

823 

824 location = data.get('location') 

825 

826 task_id = offload_task( 

827 complete_build_outputs, 

828 build.pk, 

829 outputs=[ 

830 { 

831 'output_id': item['output'].pk, 

832 'quantity': float(item['quantity']) 

833 if item.get('quantity') is not None 

834 else None, 

835 } 

836 for item in data['outputs'] 

837 ], 

838 location_id=location.pk if location else None, 

839 status=data.get('status_custom_key'), 

840 notes=data.get('notes', ''), 

841 user_id=self.request.user.pk, 

842 group='build', 

843 ) 

844 

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

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

847 

848 

849class BuildOutputDelete(BuildOrderContextMixin, CreateAPI): 

850 """API endpoint for deleting multiple build outputs.""" 

851 

852 def get_serializer_context(self): 

853 """Add extra context information to the endpoint serializer.""" 

854 ctx = super().get_serializer_context() 

855 ctx['to_complete'] = False 

856 return ctx 

857 

858 queryset = Build.objects.none() 

859 serializer_class = build.serializers.BuildOutputDeleteSerializer 

860 

861 @extend_schema(responses={200: common.serializers.TaskDetailSerializer}) 

862 def post(self, *args, **kwargs): 

863 """Override POST to offload build output deletion to the background worker.""" 

864 from build.tasks import delete_build_outputs 

865 from InvenTree.tasks import offload_task 

866 

867 build = self.get_build() 

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

869 serializer.is_valid(raise_exception=True) 

870 data = serializer.validated_data 

871 

872 task_id = offload_task( 

873 delete_build_outputs, 

874 build.pk, 

875 output_ids=[item['output'].pk for item in data['outputs']], 

876 group='build', 

877 ) 

878 

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

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

881 

882 

883class BuildFinish(BuildOrderContextMixin, CreateAPI): 

884 """API endpoint for marking a build as finished (completed).""" 

885 

886 queryset = Build.objects.none() 

887 serializer_class = build.serializers.BuildCompleteSerializer 

888 

889 def get_queryset(self): 

890 """Return the queryset for the BuildFinish API endpoint.""" 

891 queryset = super().get_queryset() 

892 queryset = queryset.prefetch_related('build_lines', 'build_lines__allocations') 

893 

894 return queryset 

895 

896 

897class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI): 

898 """API endpoint for 'automatically' allocating stock against a build order. 

899 

900 - Only looks at 'untracked' parts 

901 - If stock exists in a single location, easy! 

902 - If user decides that stock items are "fungible", allocate against multiple stock items 

903 - If the user wants to, allocate substitute parts if the primary parts are not available. 

904 """ 

905 

906 queryset = Build.objects.none() 

907 serializer_class = build.serializers.BuildAutoAllocationSerializer 

908 

909 @extend_schema(responses={200: common.serializers.TaskDetailSerializer}) 

910 def post(self, *args, **kwargs): 

911 """Override the POST method to handle auto allocation task. 

912 

913 As this is offloaded to the background task, 

914 we return information about the background task which is performing the auto allocation operation. 

915 """ 

916 from build.tasks import auto_allocate_build 

917 from InvenTree.tasks import offload_task 

918 

919 build = self.get_build() 

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

921 serializer.is_valid(raise_exception=True) 

922 data = serializer.validated_data 

923 

924 build_lines = data.get('build_lines', []) 

925 

926 # Offload the task to the background worker 

927 task_id = offload_task( 

928 auto_allocate_build, 

929 build.pk, 

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

931 exclude_location=data.get('exclude_location', None), 

932 interchangeable=data['interchangeable'], 

933 substitutes=data['substitutes'], 

934 optional_items=data['optional_items'], 

935 item_type=data.get('item_type', 'untracked'), 

936 stock_sort_by=data['stock_sort_by'], 

937 line_ids=[line.pk for line in build_lines] if build_lines else None, 

938 group='build', 

939 ) 

940 

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

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

943 

944 

945class BuildAllocate(BuildOrderContextMixin, CreateAPI): 

946 """API endpoint to allocate stock items to a build order. 

947 

948 - The BuildOrder object is specified by the URL 

949 - Items to allocate are specified as a list called "items" with the following options: 

950 - bom_item: pk value of a given BomItem object (must match the part associated with this build) 

951 - stock_item: pk value of a given StockItem object 

952 - quantity: quantity to allocate 

953 - output: StockItem (build order output) to allocate stock against (optional) 

954 """ 

955 

956 queryset = Build.objects.none() 

957 serializer_class = build.serializers.BuildAllocationSerializer 

958 

959 

960class BuildConsume(BuildOrderContextMixin, CreateAPI): 

961 """API endpoint to consume stock against a build order.""" 

962 

963 queryset = Build.objects.none() 

964 serializer_class = build.serializers.BuildConsumeSerializer 

965 

966 @extend_schema(responses={200: common.serializers.TaskDetailSerializer}) 

967 def post(self, *args, **kwargs): 

968 """Override the POST method to handle consume task. 

969 

970 As this is offloaded to the background task, 

971 we return information about the background task which is performing the consume operation. 

972 """ 

973 from build.tasks import consume_build_stock 

974 from InvenTree.tasks import offload_task 

975 

976 build = self.get_build() 

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

978 serializer.is_valid(raise_exception=True) 

979 data = serializer.validated_data 

980 

981 # Extract the information we need to consume build stock 

982 items = data.get('items', []) 

983 lines = data.get('lines', []) 

984 notes = data.get('notes', '') 

985 

986 # Offload the task to the background worker 

987 task_id = offload_task( 

988 consume_build_stock, 

989 build.pk, 

990 lines=[line['build_line'].pk for line in lines], 

991 items={item['build_item'].pk: item['quantity'] for item in items}, 

992 user_id=self.request.user.pk, 

993 notes=notes, 

994 ) 

995 

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

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

998 

999 

1000class BuildIssue(BuildOrderContextMixin, CreateAPI): 

1001 """API endpoint for issuing a BuildOrder.""" 

1002 

1003 queryset = Build.objects.all() 

1004 serializer_class = build.serializers.BuildIssueSerializer 

1005 

1006 

1007class BuildHold(BuildOrderContextMixin, CreateAPI): 

1008 """API endpoint for placing a BuildOrder on hold.""" 

1009 

1010 queryset = Build.objects.all() 

1011 serializer_class = build.serializers.BuildHoldSerializer 

1012 

1013 

1014class BuildCancel(BuildOrderContextMixin, CreateAPI): 

1015 """API endpoint for cancelling a BuildOrder.""" 

1016 

1017 queryset = Build.objects.all() 

1018 serializer_class = build.serializers.BuildCancelSerializer 

1019 

1020 

1021class BuildItemMixin: 

1022 """Mixin class for BuildItem API endpoints.""" 

1023 

1024 queryset = BuildItem.objects.all().prefetch_related('stock_item__location') 

1025 serializer_class = build.serializers.BuildItemSerializer 

1026 

1027 

1028class BuildItemDetail(BuildItemMixin, RetrieveUpdateDestroyAPI): 

1029 """API endpoint for detail view of a BuildItem object.""" 

1030 

1031 

1032class BuildItemFilter(FilterSet): 

1033 """Custom filterset for the BuildItemList API endpoint.""" 

1034 

1035 class Meta: 

1036 """Metaclass option.""" 

1037 

1038 model = BuildItem 

1039 fields = ['build_line', 'stock_item', 'install_into'] 

1040 

1041 include_variants = rest_filters.BooleanFilter( 

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

1043 ) 

1044 

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

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

1047 

1048 Note: 

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

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

1051 """ 

1052 return queryset 

1053 

1054 part = rest_filters.ModelChoiceFilter( 

1055 queryset=part_models.Part.objects.all(), 

1056 label=_('Part'), 

1057 method='filter_part', 

1058 field_name='stock_item__part', 

1059 ) 

1060 

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

1062 """Filter by 'part' which is being built. 

1063 

1064 Note: 

1065 - If "include_variants" is True, include all variants of the selected part. 

1066 - Otherwise, just filter by the selected part. 

1067 """ 

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

1069 

1070 if include_variants: 

1071 return queryset.filter( 

1072 stock_item__part__in=part.get_descendants(include_self=True) 

1073 ) 

1074 else: 

1075 return queryset.filter(stock_item__part=part) 

1076 

1077 build = rest_filters.ModelChoiceFilter( 

1078 queryset=build_models.Build.objects.all(), 

1079 label=_('Build Order'), 

1080 field_name='build_line__build', 

1081 ) 

1082 

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

1084 

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

1086 """Filter the queryset based on whether build items are tracked.""" 

1087 if str2bool(value): 

1088 return queryset.exclude(install_into=None) 

1089 return queryset.filter(install_into=None) 

1090 

1091 location = rest_filters.ModelChoiceFilter( 

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

1093 label=_('Location'), 

1094 method='filter_location', 

1095 ) 

1096 

1097 @extend_schema_field(serializers.IntegerField(help_text=_('Location'))) 

1098 def filter_location(self, queryset, name, location): 

1099 """Filter the queryset based on the specified location.""" 

1100 locations = location.get_descendants(include_self=True) 

1101 return queryset.filter(stock_item__location__in=locations) 

1102 

1103 output = NumberOrNullFilter( 

1104 field_name='install_into', 

1105 label=_('Output'), 

1106 help_text=_( 

1107 "Filter by output stock item ID. Use 'null' to find uninstalled build items." 

1108 ), 

1109 ) 

1110 

1111 

1112class BuildItemOutputOptions(OutputConfiguration): 

1113 """Output options for BuildItem endpoint.""" 

1114 

1115 OPTIONS = [ 

1116 InvenTreeOutputOption( 

1117 'part_detail', 

1118 default=False, 

1119 description='Include detailed information about the part associated with this build item.', 

1120 ), 

1121 InvenTreeOutputOption( 

1122 'location_detail', 

1123 default=False, 

1124 description='Include detailed information about the location of the allocated stock item.', 

1125 ), 

1126 InvenTreeOutputOption( 

1127 'stock_detail', 

1128 default=False, 

1129 description='Include detailed information about the allocated stock item.', 

1130 ), 

1131 InvenTreeOutputOption( 

1132 'build_detail', 

1133 default=False, 

1134 description='Include detailed information about the associated build order.', 

1135 ), 

1136 InvenTreeOutputOption( 

1137 'supplier_part_detail', 

1138 default=False, 

1139 description='Include detailed information about the supplier part associated with this build item.', 

1140 ), 

1141 InvenTreeOutputOption( 

1142 'install_into_detail', 

1143 default=False, 

1144 description='Include detailed information about the build output for this build item.', 

1145 ), 

1146 ] 

1147 

1148 

1149class BuildItemList( 

1150 BuildItemMixin, 

1151 DataExportViewMixin, 

1152 OutputOptionsMixin, 

1153 BulkDeleteMixin, 

1154 ListCreateAPI, 

1155): 

1156 """API endpoint for accessing a list of BuildItem objects. 

1157 

1158 - GET: Return list of objects 

1159 - POST: Create a new BuildItem object 

1160 """ 

1161 

1162 output_options = BuildItemOutputOptions 

1163 filterset_class = BuildItemFilter 

1164 filter_backends = SEARCH_ORDER_FILTER 

1165 

1166 def get_queryset(self): 

1167 """Override the queryset method, to perform custom prefetch.""" 

1168 queryset = super().get_queryset() 

1169 

1170 queryset = queryset.select_related('install_into').prefetch_related( 

1171 'build_line', 'build_line__build', 'build_line__bom_item' 

1172 ) 

1173 

1174 return queryset 

1175 

1176 ordering_fields = ['part', 'sku', 'quantity', 'location', 'reference', 'IPN'] 

1177 

1178 ordering_field_aliases = { 

1179 'part': 'stock_item__part__name', 

1180 'IPN': 'stock_item__part__IPN', 

1181 'sku': 'stock_item__supplier_part__SKU', 

1182 'location': 'stock_item__location__name', 

1183 'reference': 'build_line__bom_item__reference', 

1184 } 

1185 

1186 search_fields = [ 

1187 'stock_item__supplier_part__SKU', 

1188 'stock_item__part__name', 

1189 'stock_item__part__IPN', 

1190 'build_line__bom_item__reference', 

1191 ] 

1192 

1193 

1194build_api_urls = [ 

1195 # Build lines 

1196 path( 

1197 'line/', 

1198 include([ 

1199 path('<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'), 

1200 path('', BuildLineList.as_view(), name='api-build-line-list'), 

1201 ]), 

1202 ), 

1203 # Build Items 

1204 path( 

1205 'item/', 

1206 include([ 

1207 path( 

1208 '<int:pk>/', 

1209 include([ 

1210 meta_path(BuildItem), 

1211 path('', BuildItemDetail.as_view(), name='api-build-item-detail'), 

1212 ]), 

1213 ), 

1214 path('', BuildItemList.as_view(), name='api-build-item-list'), 

1215 ]), 

1216 ), 

1217 # Build Detail 

1218 path( 

1219 '<int:pk>/', 

1220 include([ 

1221 path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'), 

1222 path('consume/', BuildConsume.as_view(), name='api-build-consume'), 

1223 path( 

1224 'auto-allocate/', 

1225 BuildAutoAllocate.as_view(), 

1226 name='api-build-auto-allocate', 

1227 ), 

1228 path( 

1229 'complete/', 

1230 BuildOutputComplete.as_view(), 

1231 name='api-build-output-complete', 

1232 ), 

1233 path( 

1234 'create-output/', 

1235 BuildOutputCreate.as_view(), 

1236 name='api-build-output-create', 

1237 ), 

1238 path( 

1239 'delete-outputs/', 

1240 BuildOutputDelete.as_view(), 

1241 name='api-build-output-delete', 

1242 ), 

1243 path( 

1244 'scrap-outputs/', 

1245 BuildOutputScrap.as_view(), 

1246 name='api-build-output-scrap', 

1247 ), 

1248 path('issue/', BuildIssue.as_view(), name='api-build-issue'), 

1249 path('hold/', BuildHold.as_view(), name='api-build-hold'), 

1250 path('finish/', BuildFinish.as_view(), name='api-build-finish'), 

1251 path('cancel/', BuildCancel.as_view(), name='api-build-cancel'), 

1252 path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), 

1253 meta_path(Build), 

1254 path('', BuildDetail.as_view(), name='api-build-detail'), 

1255 ]), 

1256 ), 

1257 # Build order status code information 

1258 path( 

1259 'status/', 

1260 StatusView.as_view(), 

1261 {StatusView.MODEL_REF: BuildStatus}, 

1262 name='api-build-status-codes', 

1263 ), 

1264 # Build List 

1265 path('', BuildList.as_view(), name='api-build-list'), 

1266]