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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""JSON API for the Build app."""
3from __future__ import annotations
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 _
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
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
48class BuildFilter(FilterSet):
49 """Custom filterset for BuildList API endpoint."""
51 class Meta:
52 """Metaclass options."""
54 model = Build
55 fields = ['issued_by', 'sales_order', 'external']
57 status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
59 def filter_status(self, queryset, name, value):
60 """Filter by integer status code.
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)
67 return queryset.filter(q1 | q2).distinct()
69 active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
71 # 'outstanding' is an alias for 'active' here
72 outstanding = rest_filters.BooleanFilter(
73 label='Build is outstanding', method='filter_active'
74 )
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)
82 parent = rest_filters.ModelChoiceFilter(
83 queryset=Build.objects.all(), label=_('Parent Build'), field_name='parent'
84 )
86 include_variants = rest_filters.BooleanFilter(
87 label=_('Include Variants'), method='filter_include_variants'
88 )
90 def filter_include_variants(self, queryset, name, value):
91 """Filter by whether or not to include variants of the selected part.
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
99 part = rest_filters.ModelChoiceFilter(
100 queryset=part_models.Part.objects.all(),
101 field_name='part',
102 method='filter_part',
103 label=_('Part'),
104 )
106 def filter_part(self, queryset, name, part):
107 """Filter by 'part' which is being built.
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))
115 if include_variants:
116 return queryset.filter(part__in=part.get_descendants(include_self=True))
117 else:
118 return queryset.filter(part=part)
120 category = rest_filters.ModelChoiceFilter(
121 queryset=part_models.PartCategory.objects.all(),
122 method='filter_category',
123 label=_('Category'),
124 )
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)
132 ancestor = rest_filters.ModelChoiceFilter(
133 queryset=Build.objects.all(),
134 label=_('Ancestor Build'),
135 method='filter_ancestor',
136 )
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])
144 overdue = rest_filters.BooleanFilter(
145 label='Build is overdue', method='filter_overdue'
146 )
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())
154 assigned_to_me = rest_filters.BooleanFilter(
155 label=_('Assigned to me'), method='filter_assigned_to_me'
156 )
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)
162 # Work out who "me" is!
163 owners = Owner.get_owners_matching_user(self.request.user)
165 if value:
166 return queryset.filter(responsible__in=owners)
167 return queryset.exclude(responsible__in=owners)
169 assigned_to = rest_filters.ModelChoiceFilter(
170 queryset=Owner.objects.all(), field_name='responsible', label=_('Assigned To')
171 )
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))
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 )
183 return queryset.filter(responsible__in=owners)
185 # Exact match for reference
186 reference = rest_filters.CharFilter(
187 label='Filter by exact reference', field_name='reference', lookup_expr='iexact'
188 )
190 project_code = rest_filters.ModelChoiceFilter(
191 queryset=common.models.ProjectCode.objects.all(), field_name='project_code'
192 )
194 has_project_code = rest_filters.BooleanFilter(
195 label='has_project_code', method='filter_has_project_code'
196 )
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)
204 created_before = InvenTreeDateFilter(
205 label=_('Created before'), field_name='creation_date', lookup_expr='lt'
206 )
208 created_after = InvenTreeDateFilter(
209 label=_('Created after'), field_name='creation_date', lookup_expr='gt'
210 )
212 has_start_date = rest_filters.BooleanFilter(
213 label=_('Has start date'), method='filter_has_start_date'
214 )
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))
220 start_date_before = InvenTreeDateFilter(
221 label=_('Start date before'), field_name='start_date', lookup_expr='lt'
222 )
224 start_date_after = InvenTreeDateFilter(
225 label=_('Start date after'), field_name='start_date', lookup_expr='gt'
226 )
228 has_target_date = rest_filters.BooleanFilter(
229 label=_('Has target date'), method='filter_has_target_date'
230 )
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))
236 target_date_before = InvenTreeDateFilter(
237 label=_('Target date before'), field_name='target_date', lookup_expr='lt'
238 )
240 target_date_after = InvenTreeDateFilter(
241 label=_('Target date after'), field_name='target_date', lookup_expr='gt'
242 )
244 completed_before = InvenTreeDateFilter(
245 label=_('Completed before'), field_name='completion_date', lookup_expr='lt'
246 )
248 completed_after = InvenTreeDateFilter(
249 label=_('Completed after'), field_name='completion_date', lookup_expr='gt'
250 )
252 min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date')
254 def filter_min_date(self, queryset, name, value):
255 """Filter the queryset to include orders *after* a specified date.
257 This filter is used in combination with filter_max_date,
258 to provide a queryset which matches a particular range of dates.
260 In particular, this is used in the UI for the calendar view.
262 So, we are interested in orders which are active *after* this date:
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
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)
273 return queryset.filter(q1 | q2 | q3).distinct()
275 max_date = InvenTreeDateFilter(label=_('Max Date'), method='filter_max_date')
277 def filter_max_date(self, queryset, name, value):
278 """Filter the queryset to include orders *before* a specified date.
280 This filter is used in combination with filter_min_date,
281 to provide a queryset which matches a particular range of dates.
283 In particular, this is used in the UI for the calendar view.
285 So, we are interested in orders which are active *before* this date:
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)
295 return queryset.filter(q1 | q2 | q3).distinct()
297 exclude_tree = rest_filters.ModelChoiceFilter(
298 queryset=Build.objects.all(),
299 method='filter_exclude_tree',
300 label=_('Exclude Tree'),
301 )
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 )
310 return queryset
312 tags = common.filters.TagsFilter()
315class BuildMixin:
316 """Mixin class for Build API endpoints."""
318 queryset = Build.objects.all()
319 serializer_class = build.serializers.BuildSerializer
321 def get_queryset(self):
322 """Return the queryset for the Build API endpoints."""
323 queryset = super().get_queryset()
325 queryset = build.serializers.BuildSerializer.annotate_queryset(queryset)
327 return queryset
330class BuildListOutputOptions(OutputConfiguration):
331 """Output options for the BuildList endpoint."""
333 OPTIONS = [InvenTreeOutputOption('part_detail', default=True)]
336class BuildList(
337 DataExportViewMixin,
338 BuildMixin,
339 OutputOptionsMixin,
340 ParameterListMixin,
341 ListCreateAPI,
342):
343 """API endpoint for accessing a list of Build objects.
345 - GET: Return list of objects (with filters)
346 - POST: Create a new Build object
347 """
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 ]
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)
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)
398 serializer.save(issued_by=request.user)
400 headers = self.get_success_headers(serializer.data)
401 return Response(
402 serializer.data, status=status.HTTP_201_CREATED, headers=headers
403 )
406class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI):
407 """API endpoint for detail view of a Build object."""
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()
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 })
420 return super().destroy(request, *args, **kwargs)
423class BuildUnallocate(CreateAPI):
424 """API endpoint for unallocating stock items from a build order.
426 - The BuildOrder object is specified by the URL
427 - "output" (StockItem) can optionally be specified
428 - "bom_item" can optionally be specified
429 """
431 queryset = Build.objects.none()
433 serializer_class = build.serializers.BuildUnallocationSerializer
435 def get_serializer_context(self):
436 """Add extra context information to the endpoint serializer."""
437 ctx = super().get_serializer_context()
439 try:
440 ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
441 except Exception:
442 pass
444 ctx['request'] = self.request
446 return ctx
449class BuildLineFilter(FilterSet):
450 """Custom filterset for the BuildLine API endpoint."""
452 class Meta:
453 """Meta information for the BuildLineFilter class."""
455 model = BuildLine
456 fields = ['build', 'bom_item']
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 )
475 part = rest_filters.ModelChoiceFilter(
476 queryset=part_models.Part.objects.all(),
477 label=_('Part'),
478 field_name='bom_item__sub_part',
479 )
481 order_outstanding = rest_filters.BooleanFilter(
482 label=_('Order Outstanding'), method='filter_order_outstanding'
483 )
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)
491 allocated = rest_filters.BooleanFilter(
492 label=_('Allocated'), method='filter_allocated'
493 )
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 )
505 queryset = queryset.alias(
506 allocated_quantity=Coalesce(
507 Subquery(allocated_subquery), 0, output_field=DecimalField()
508 )
509 )
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'))
517 consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed')
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'))
525 available = rest_filters.BooleanFilter(
526 label=_('Available'), method='filter_available'
527 )
529 def filter_available(self, queryset, name, value):
530 """Filter by whether there is sufficient stock available for each BuildLine.
532 To determine this, we need to know:
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 )
546 queryset = queryset.alias(
547 allocated_quantity=Coalesce(
548 Subquery(allocated_subquery), 0, output_field=DecimalField()
549 )
550 )
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 )
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)
565 return queryset.filter(quantity__gt=available)
567 on_order = rest_filters.BooleanFilter(label=_('On Order'), method='filter_on_order')
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)
577class BuildLineMixin(SerializerContextMixin):
578 """Mixin class for BuildLine API endpoints."""
580 queryset = BuildLine.objects.all()
581 serializer_class = build.serializers.BuildLineSerializer
583 def get_source_build(self) -> Build:
584 """Return the source Build object for the BuildLine queryset.
586 This source build is used to filter the available stock for each BuildLine.
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 )
595 def get_queryset(self):
596 """Override queryset to select-related and annotate."""
597 queryset = super().get_queryset()
599 if not hasattr(self, 'source_build'):
600 self.source_build = self.get_source_build()
602 source_build = self.source_build
604 return build.serializers.BuildLineSerializer.annotate_queryset(
605 queryset, build=source_build
606 )
609class BuildLineOutputOptions(OutputConfiguration):
610 """Output options for BuildLine endpoint."""
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 ]
641class BuildLineList(
642 BuildLineMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
643):
644 """API endpoint for accessing a list of BuildLine objects."""
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 ]
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 }
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 ]
688 def get_source_build(self) -> Build | None:
689 """Return the target build for the BuildLine queryset."""
690 source_build = None
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
699 return source_build
702class BuildLineDetail(BuildLineMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
703 """API endpoint for detail view of a BuildLine object."""
705 output_options = BuildLineOutputOptions
707 def get_source_build(self) -> Build | None:
708 """Return the target source location for the BuildLine queryset."""
709 return None
712class BuildOrderContextMixin:
713 """Mixin class which adds build order as serializer context variable."""
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'))
722 def get_serializer_context(self):
723 """Add extra context information to the endpoint serializer."""
724 ctx = super().get_serializer_context()
726 ctx['request'] = self.request
727 ctx['to_complete'] = True
729 try:
730 ctx['build'] = self.get_build()
731 except NotFound:
732 pass
734 return ctx
737@extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)})
738class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
739 """API endpoint for creating new build output(s)."""
741 queryset = Build.objects.none()
743 serializer_class = build.serializers.BuildOutputCreateSerializer
744 pagination_class = None
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)
751 # Create the build output(s)
752 outputs = serializer.save()
754 queryset = stock.serializers.StockItemSerializer.annotate_queryset(outputs)
755 response = stock.serializers.StockItemSerializer(queryset, many=True)
757 # Return the created outputs
758 return Response(response.data, status=status.HTTP_201_CREATED)
761class BuildOutputScrap(BuildOrderContextMixin, CreateAPI):
762 """API endpoint for scrapping build output(s)."""
764 queryset = Build.objects.none()
765 serializer_class = build.serializers.BuildOutputScrapSerializer
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
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
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
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 )
803 response = common.serializers.TaskDetailSerializer.from_task(task_id).data
804 return Response(response, status=response['http_status'])
807class BuildOutputComplete(BuildOrderContextMixin, CreateAPI):
808 """API endpoint for completing build outputs."""
810 queryset = Build.objects.none()
811 serializer_class = build.serializers.BuildOutputCompleteSerializer
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
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
824 location = data.get('location')
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 )
845 response = common.serializers.TaskDetailSerializer.from_task(task_id).data
846 return Response(response, status=response['http_status'])
849class BuildOutputDelete(BuildOrderContextMixin, CreateAPI):
850 """API endpoint for deleting multiple build outputs."""
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
858 queryset = Build.objects.none()
859 serializer_class = build.serializers.BuildOutputDeleteSerializer
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
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
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 )
879 response = common.serializers.TaskDetailSerializer.from_task(task_id).data
880 return Response(response, status=response['http_status'])
883class BuildFinish(BuildOrderContextMixin, CreateAPI):
884 """API endpoint for marking a build as finished (completed)."""
886 queryset = Build.objects.none()
887 serializer_class = build.serializers.BuildCompleteSerializer
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')
894 return queryset
897class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
898 """API endpoint for 'automatically' allocating stock against a build order.
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 """
906 queryset = Build.objects.none()
907 serializer_class = build.serializers.BuildAutoAllocationSerializer
909 @extend_schema(responses={200: common.serializers.TaskDetailSerializer})
910 def post(self, *args, **kwargs):
911 """Override the POST method to handle auto allocation task.
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
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
924 build_lines = data.get('build_lines', [])
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 )
941 response = common.serializers.TaskDetailSerializer.from_task(task_id).data
942 return Response(response, status=response['http_status'])
945class BuildAllocate(BuildOrderContextMixin, CreateAPI):
946 """API endpoint to allocate stock items to a build order.
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 """
956 queryset = Build.objects.none()
957 serializer_class = build.serializers.BuildAllocationSerializer
960class BuildConsume(BuildOrderContextMixin, CreateAPI):
961 """API endpoint to consume stock against a build order."""
963 queryset = Build.objects.none()
964 serializer_class = build.serializers.BuildConsumeSerializer
966 @extend_schema(responses={200: common.serializers.TaskDetailSerializer})
967 def post(self, *args, **kwargs):
968 """Override the POST method to handle consume task.
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
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
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', '')
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 )
996 response = common.serializers.TaskDetailSerializer.from_task(task_id).data
997 return Response(response, status=response['http_status'])
1000class BuildIssue(BuildOrderContextMixin, CreateAPI):
1001 """API endpoint for issuing a BuildOrder."""
1003 queryset = Build.objects.all()
1004 serializer_class = build.serializers.BuildIssueSerializer
1007class BuildHold(BuildOrderContextMixin, CreateAPI):
1008 """API endpoint for placing a BuildOrder on hold."""
1010 queryset = Build.objects.all()
1011 serializer_class = build.serializers.BuildHoldSerializer
1014class BuildCancel(BuildOrderContextMixin, CreateAPI):
1015 """API endpoint for cancelling a BuildOrder."""
1017 queryset = Build.objects.all()
1018 serializer_class = build.serializers.BuildCancelSerializer
1021class BuildItemMixin:
1022 """Mixin class for BuildItem API endpoints."""
1024 queryset = BuildItem.objects.all().prefetch_related('stock_item__location')
1025 serializer_class = build.serializers.BuildItemSerializer
1028class BuildItemDetail(BuildItemMixin, RetrieveUpdateDestroyAPI):
1029 """API endpoint for detail view of a BuildItem object."""
1032class BuildItemFilter(FilterSet):
1033 """Custom filterset for the BuildItemList API endpoint."""
1035 class Meta:
1036 """Metaclass option."""
1038 model = BuildItem
1039 fields = ['build_line', 'stock_item', 'install_into']
1041 include_variants = rest_filters.BooleanFilter(
1042 label=_('Include Variants'), method='filter_include_variants'
1043 )
1045 def filter_include_variants(self, queryset, name, value):
1046 """Filter by whether or not to include variants of the selected part.
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
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 )
1061 def filter_part(self, queryset, name, part):
1062 """Filter by 'part' which is being built.
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))
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)
1077 build = rest_filters.ModelChoiceFilter(
1078 queryset=build_models.Build.objects.all(),
1079 label=_('Build Order'),
1080 field_name='build_line__build',
1081 )
1083 tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
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)
1091 location = rest_filters.ModelChoiceFilter(
1092 queryset=stock_models.StockLocation.objects.all(),
1093 label=_('Location'),
1094 method='filter_location',
1095 )
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)
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 )
1112class BuildItemOutputOptions(OutputConfiguration):
1113 """Output options for BuildItem endpoint."""
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 ]
1149class BuildItemList(
1150 BuildItemMixin,
1151 DataExportViewMixin,
1152 OutputOptionsMixin,
1153 BulkDeleteMixin,
1154 ListCreateAPI,
1155):
1156 """API endpoint for accessing a list of BuildItem objects.
1158 - GET: Return list of objects
1159 - POST: Create a new BuildItem object
1160 """
1162 output_options = BuildItemOutputOptions
1163 filterset_class = BuildItemFilter
1164 filter_backends = SEARCH_ORDER_FILTER
1166 def get_queryset(self):
1167 """Override the queryset method, to perform custom prefetch."""
1168 queryset = super().get_queryset()
1170 queryset = queryset.select_related('install_into').prefetch_related(
1171 'build_line', 'build_line__build', 'build_line__bom_item'
1172 )
1174 return queryset
1176 ordering_fields = ['part', 'sku', 'quantity', 'location', 'reference', 'IPN']
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 }
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 ]
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]