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
« 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."""
3from django.db.models import Count, F, Q
4from django.urls import include, path
5from django.utils.translation import gettext_lazy as _
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
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
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)
67class CategoryMixin:
68 """Mixin class for PartCategory endpoints."""
70 serializer_class = part_serializers.CategorySerializer
71 queryset = PartCategory.objects.all()
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
80class CategoryFilter(FilterSet):
81 """Custom filterset class for the PartCategoryList endpoint."""
83 class Meta:
84 """Metaclass options for this filterset."""
86 model = PartCategory
87 fields = ['name', 'structural']
89 starred = rest_filters.BooleanFilter(
90 label=_('Starred'),
91 method='filter_starred',
92 help_text=_('Filter by starred categories'),
93 )
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
99 starred_categories = [
100 star.category.pk for star in user.starred_categories.all()
101 ]
103 if str2bool(value):
104 return queryset.filter(pk__in=starred_categories)
106 return queryset.exclude(pk__in=starred_categories)
108 depth = rest_filters.NumberFilter(
109 label=_('Depth'), method='filter_depth', help_text=_('Filter by category depth')
110 )
112 def filter_depth(self, queryset, name, value):
113 """Filter by the "depth" of the PartCategory.
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)
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)
124 return queryset
126 top_level = rest_filters.BooleanFilter(
127 label=_('Top Level'),
128 method='filter_top_level',
129 help_text=_('Filter by top-level categories'),
130 )
132 def filter_top_level(self, queryset, name, value):
133 """Filter by top-level categories."""
134 cascade = str2bool(self.data.get('cascade', False))
136 if value and not cascade:
137 return queryset.filter(parent=None)
139 return queryset
141 cascade = rest_filters.BooleanFilter(
142 label=_('Cascade'),
143 method='filter_cascade',
144 help_text=_('Include sub-categories in filtered results'),
145 )
147 def filter_cascade(self, queryset, name, value):
148 """Filter by whether to include sub-categories in the filtered results.
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))
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)
160 return queryset
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 )
169 def filter_parent(self, queryset, name, value):
170 """Filter by parent category.
172 Note that the filtering behaviour here varies,
173 depending on whether the 'cascade' value is set.
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))
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)
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)
195 return queryset
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 )
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)
210 # Exclude any sub-categories also
211 queryset = queryset.exclude(parent__in=value.get_descendants(include_self=True))
213 return queryset
216class CategoryOutputOption(OutputConfiguration):
217 """Output option for PartCategory endpoints."""
219 OPTIONS = [InvenTreeOutputOption(flag='path_detail')]
222class CategoryList(
223 CategoryMixin,
224 BulkUpdateMixin,
225 DataExportViewMixin,
226 OutputOptionsMixin,
227 ListCreateAPI,
228):
229 """API endpoint for accessing a list of PartCategory objects.
231 - GET: Return a list of PartCategory objects
232 - POST: Create a new PartCategory object
233 """
235 filterset_class = CategoryFilter
237 filter_backends = SEARCH_ORDER_FILTER
239 output_options = CategoryOutputOption
241 ordering_fields = ['name', 'pathstring', 'level', 'tree_id', 'lft', 'part_count']
243 # Use hierarchical ordering by default
244 ordering = ['tree_id', 'lft', 'name']
246 search_fields = ['name', 'description', 'pathstring']
249class CategoryDetail(CategoryMixin, OutputOptionsMixin, CustomRetrieveUpdateDestroyAPI):
250 """API endpoint for detail view of a single PartCategory object."""
252 output_options = CategoryOutputOption
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)
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))
263 self.get_object().set_starred(request.user, starred, include_parents=False)
265 return response
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)
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 )
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 )
288class CategoryTreeFilter(FilterSet):
289 """Custom filterset class for the CategoryTree endpoint."""
291 class Meta:
292 """Metaclass options for this filterset."""
294 model = PartCategory
295 fields = ['parent', 'tree_id', 'level']
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 )
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)
308class CategoryTree(TreeMixin, ListAPI):
309 """API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
311 model_class = PartCategory
312 queryset = PartCategory.objects.all()
313 serializer_class = part_serializers.CategoryTreeSerializer
314 filterset_class = CategoryTreeFilter
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
323class CategoryParameterList(DataExportViewMixin, OutputOptionsMixin, ListCreateAPI):
324 """API endpoint for accessing a list of PartCategoryParameterTemplate objects.
326 - GET: Return a list of PartCategoryParameterTemplate objects
327 """
329 queryset = PartCategoryParameterTemplate.objects.all()
330 serializer_class = part_serializers.CategoryParameterTemplateSerializer
332 def get_queryset(self):
333 """Custom filtering.
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()
342 params = self.request.query_params
344 category = params.get('category', None)
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)
350 fetch_parent = str2bool(params.get('fetch_parent', True))
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)
358 except (ValueError, PartCategory.DoesNotExist):
359 pass
361 return queryset
364class CategoryParameterDetail(RetrieveUpdateDestroyAPI):
365 """Detail endpoint for the PartCategoryParameterTemplate model."""
367 queryset = PartCategoryParameterTemplate.objects.all()
368 serializer_class = part_serializers.CategoryParameterTemplateSerializer
371class PartSalePriceDetail(RetrieveUpdateDestroyAPI):
372 """Detail endpoint for PartSellPriceBreak model."""
374 queryset = PartSellPriceBreak.objects.all()
375 serializer_class = part_serializers.PartSalePriceSerializer
378class PartSalePriceList(DataExportViewMixin, ListCreateAPI):
379 """API endpoint for list view of PartSalePriceBreak model."""
381 queryset = PartSellPriceBreak.objects.all()
382 serializer_class = part_serializers.PartSalePriceSerializer
384 filter_backends = SEARCH_ORDER_FILTER
385 filterset_fields = ['part']
386 ordering_fields = ['quantity', 'price']
387 ordering = 'quantity'
390class PartInternalPriceDetail(RetrieveUpdateDestroyAPI):
391 """Detail endpoint for PartInternalPriceBreak model."""
393 queryset = PartInternalPriceBreak.objects.all()
394 serializer_class = part_serializers.PartInternalPriceSerializer
397class PartInternalPriceList(DataExportViewMixin, ListCreateAPI):
398 """API endpoint for list view of PartInternalPriceBreak model."""
400 queryset = PartInternalPriceBreak.objects.all()
401 serializer_class = part_serializers.PartInternalPriceSerializer
402 permission_required = 'roles.sales_order.show'
404 filter_backends = SEARCH_ORDER_FILTER
405 filterset_fields = ['part']
406 ordering_fields = ['quantity', 'price']
407 ordering = 'quantity'
410class PartTestTemplateFilter(FilterSet):
411 """Custom filterset class for the PartTestTemplateList endpoint."""
413 class Meta:
414 """Metaclass options for this filterset."""
416 model = PartTestTemplate
417 fields = ['enabled', 'key', 'required', 'requires_attachment', 'requires_value']
419 part = rest_filters.ModelChoiceFilter(
420 queryset=Part.objects.filter(testable=True),
421 label='Part',
422 field_name='part',
423 method='filter_part',
424 )
426 def filter_part(self, queryset, name, part):
427 """Filter by the 'part' field.
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 )
436 if include_inherited:
437 return queryset.filter(part__in=part.get_ancestors(include_self=True))
438 else:
439 return queryset.filter(part=part)
441 has_results = rest_filters.BooleanFilter(
442 label=_('Has Results'), method='filter_has_results'
443 )
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)
452class PartTestTemplateMixin:
453 """Mixin class for the PartTestTemplate API endpoints."""
455 queryset = PartTestTemplate.objects.all()
456 serializer_class = part_serializers.PartTestTemplateSerializer
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
467class PartTestTemplateDetail(PartTestTemplateMixin, RetrieveUpdateDestroyAPI):
468 """Detail endpoint for PartTestTemplate model."""
471class PartTestTemplateList(PartTestTemplateMixin, DataExportViewMixin, ListCreateAPI):
472 """API endpoint for listing (and creating) a PartTestTemplate."""
474 filterset_class = PartTestTemplateFilter
476 filter_backends = SEARCH_ORDER_FILTER
478 search_fields = ['test_name', 'description']
480 ordering_fields = [
481 'enabled',
482 'required',
483 'requires_value',
484 'requires_attachment',
485 'results',
486 'test_name',
487 ]
489 ordering = 'test_name'
492class PartThumbs(ListAPI):
493 """API endpoint for retrieving information on available Part thumbnails."""
495 queryset = Part.objects.all()
496 serializer_class = part_serializers.PartThumbSerializer
498 def get_queryset(self):
499 """Return a queryset which excludes any parts without images."""
500 queryset = super().get_queryset()
502 # Get all Parts which have an associated image
503 queryset = queryset.exclude(image='')
505 return queryset
507 def list(self, request, *args, **kwargs):
508 """Serialize the available Part images.
510 - Images may be used for multiple parts!
511 """
512 queryset = self.filter_queryset(self.get_queryset())
514 # Return the most popular parts first
515 data = (
516 queryset.values('image').annotate(count=Count('image')).order_by('-count')
517 )
519 page = self.paginate_queryset(data)
521 if page is not None:
522 serializer = self.get_serializer(page, many=True)
523 else:
524 serializer = self.get_serializer(data, many=True)
526 data = serializer.data
528 if page is not None:
529 return self.get_paginated_response(data)
530 else:
531 return Response(data)
533 filter_backends = [InvenTreeSearchFilter]
535 search_fields = [
536 'name',
537 'description',
538 'IPN',
539 'revision',
540 'keywords',
541 'category__name',
542 ]
545class PartThumbsUpdate(RetrieveUpdateAPI):
546 """API endpoint for updating Part thumbnails."""
548 queryset = Part.objects.all()
549 serializer_class = part_serializers.PartThumbSerializerUpdate
551 filter_backends = [DjangoFilterBackend]
554class PartRequirements(RetrieveAPI):
555 """API endpoint detailing 'requirements' information for a particular part.
557 This endpoint returns information on upcoming requirements for:
559 - Sales Orders
560 - Build Orders
561 - Total requirements
562 - How many of this part can be assembled with available stock
564 As this data is somewhat complex to calculate, is it not included in the default API
565 """
567 queryset = Part.objects.all()
568 serializer_class = part_serializers.PartRequirementsSerializer
571class PartPricingDetail(RetrieveUpdateAPI):
572 """API endpoint for viewing part pricing data."""
574 serializer_class = part_serializers.PartPricingSerializer
575 queryset = Part.objects.all().select_related('pricing_data')
577 def get_object(self):
578 """Return the PartPricing object associated with the linked Part."""
579 part = super().get_object()
580 return part.pricing
582 def _get_serializer(self, *args, **kwargs):
583 """Return a part pricing serializer object."""
584 part = self.get_object()
585 kwargs['instance'] = part.pricing
587 return self.serializer_class(**kwargs)
590class PartSerialNumberDetail(RetrieveAPI):
591 """API endpoint for returning extra serial number information about a particular part."""
593 queryset = Part.objects.all()
594 serializer_class = part_serializers.PartSerialNumberSerializer
597class PartCopyBOM(CreateAPI):
598 """API endpoint for duplicating a BOM."""
600 queryset = Part.objects.all()
601 serializer_class = part_serializers.PartCopyBOMSerializer
603 def get_serializer_context(self):
604 """Add custom information to the serializer context for this endpoint."""
605 ctx = super().get_serializer_context()
607 try:
608 ctx['part'] = Part.objects.get(pk=self.kwargs.get('pk', None))
609 except Exception:
610 pass
612 return ctx
615class PartValidateBOM(RetrieveUpdateAPI):
616 """API endpoint for 'validating' the BOM for a given Part."""
618 queryset = Part.objects.all()
619 serializer_class = part_serializers.PartBomValidateSerializer
620 role_required = 'bom'
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.
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()
636 partial = kwargs.pop('partial', False)
638 # Clean up input data before using it
639 data = self.clean_data(request.data)
641 serializer = self.get_serializer(part, data=data, partial=partial)
642 serializer.is_valid(raise_exception=True)
644 valid = str2bool(serializer.validated_data.get('valid', False))
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 )
655 response = common.serializers.TaskDetailSerializer.from_task(task_id).data
656 return Response(response, status=response['http_status'])
659class PartFilter(FilterSet):
660 """Custom filters for the PartList endpoint.
662 Uses the django_filters extension framework
663 """
665 class Meta:
666 """Metaclass options for this filter set."""
668 model = Part
669 fields = ['revision_of']
671 is_variant = rest_filters.BooleanFilter(
672 label=_('Is Variant'), method='filter_is_variant'
673 )
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))
679 is_revision = rest_filters.BooleanFilter(
680 label=_('Is Revision'), method='filter_is_revision'
681 )
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)
689 has_revisions = rest_filters.BooleanFilter(
690 label=_('Has Revisions'), method='filter_has_revisions'
691 )
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)
699 has_units = rest_filters.BooleanFilter(label='Has units', method='filter_has_units')
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=''))
706 return queryset.filter(Q(units=None) | Q(units='')).distinct()
708 # Filter by parts which have (or not) an IPN value
709 has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn')
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()
717 # Regex filter for name
718 name_regex = rest_filters.CharFilter(
719 label='Filter by name (regex)', field_name='name', lookup_expr='iregex'
720 )
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 )
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 )
736 # low_stock filter
737 low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock')
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')))
750 high_stock = rest_filters.BooleanFilter(
751 label='High stock', method='filter_high_stock'
752 )
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()
767 # has_stock filter
768 has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock')
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))
776 # unallocated_stock filter
777 unallocated_stock = rest_filters.BooleanFilter(
778 label='Unallocated stock', method='filter_unallocated_stock'
779 )
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))
787 convert_from = rest_filters.ModelChoiceFilter(
788 label='Can convert from',
789 queryset=Part.objects.all(),
790 method='filter_convert_from',
791 )
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()
798 queryset = queryset.filter(pk__in=conversion_options)
800 return queryset
802 exclude_tree = rest_filters.ModelChoiceFilter(
803 label='Exclude Part tree',
804 queryset=Part.objects.all(),
805 method='filter_exclude_tree',
806 )
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)
813 return queryset.exclude(id__in=children)
815 ancestor = rest_filters.ModelChoiceFilter(
816 label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor'
817 )
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)
825 variant_of = rest_filters.ModelChoiceFilter(
826 label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of'
827 )
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())
833 in_bom_for = rest_filters.ModelChoiceFilter(
834 label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom'
835 )
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])
843 has_pricing = rest_filters.BooleanFilter(
844 label='Has Pricing', method='filter_has_pricing'
845 )
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)
852 if str2bool(value):
853 return queryset.exclude(q_a | q_b)
855 return queryset.filter(q_a | q_b).distinct()
857 stock_to_build = rest_filters.BooleanFilter(
858 label='Required for Build Order', method='filter_stock_to_build'
859 )
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 )
873 depleted_stock = rest_filters.BooleanFilter(
874 label='Depleted Stock', method='filter_depleted_stock'
875 )
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))
883 default_location = rest_filters.ModelChoiceFilter(
884 label='Default Location', queryset=StockLocation.objects.all()
885 )
887 bom_valid = rest_filters.BooleanFilter(
888 label=_('BOM Valid'), field_name='bom_validated'
889 )
891 starred = rest_filters.BooleanFilter(label='Starred', method='filter_starred')
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
898 starred_parts = [
899 star.part.pk
900 for star in self.request.user.starred_parts.all().prefetch_related('part')
901 ]
903 if value:
904 return queryset.filter(pk__in=starred_parts)
905 else:
906 return queryset.exclude(pk__in=starred_parts)
908 is_template = rest_filters.BooleanFilter()
910 assembly = rest_filters.BooleanFilter()
912 component = rest_filters.BooleanFilter()
914 trackable = rest_filters.BooleanFilter()
916 testable = rest_filters.BooleanFilter()
918 purchaseable = rest_filters.BooleanFilter()
920 salable = rest_filters.BooleanFilter()
922 active = rest_filters.BooleanFilter()
924 locked = rest_filters.BooleanFilter()
926 virtual = rest_filters.BooleanFilter()
928 tags = common.filters.TagsFilter()
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 )
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 )
945 related = rest_filters.NumberFilter(
946 method='filter_related_parts', help_text='Show parts related to this part ID'
947 )
949 exclude_related = rest_filters.NumberFilter(
950 method='filter_exclude_related_parts',
951 help_text='Exclude parts related to this part ID',
952 )
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
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()
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
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
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
983 relation_filter = Q(part_1=related_part) | Q(part_2=related_part)
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)
991 return part_ids
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 )
999 category = NumberOrNullFilter(
1000 method='filter_category',
1001 label=_('Category'),
1002 help_text=_("Filter by numeric category ID or the literal 'null'"),
1003 )
1005 def filter_cascade(self, queryset, name, value):
1006 """Dummy filter method for 'cascade'.
1008 - Ensures 'cascade' appears in API documentation
1009 - Does NOT actually filter the queryset directly
1010 """
1011 return queryset
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))
1017 if value == 'null':
1018 if not cascade:
1019 return queryset.filter(category=None)
1020 return queryset
1022 if not cascade:
1023 return queryset.filter(category=value)
1025 try:
1026 category = PartCategory.objects.get(pk=value)
1027 except PartCategory.DoesNotExist:
1028 return queryset
1030 children = category.getUniqueChildren()
1031 return queryset.filter(category__in=children)
1034class PartMixin(SerializerContextMixin):
1035 """Mixin class for Part API endpoints."""
1037 serializer_class = part_serializers.PartSerializer
1038 queryset = (
1039 Part.objects.all().select_related('pricing_data').prefetch_related('category')
1040 )
1042 starred_parts = None
1043 is_create = False
1045 def get_queryset(self, *args, **kwargs):
1046 """Return an annotated queryset object for the PartDetail endpoint."""
1047 queryset = super().get_queryset(*args, **kwargs)
1049 queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
1051 return queryset
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
1058 return super().get_serializer(*args, **kwargs)
1061class PartOutputOptions(OutputConfiguration):
1062 """Output options for Part endpoints."""
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 ]
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."""
1086 output_options = PartOutputOptions
1087 filterset_class = PartFilter
1088 is_create = True
1090 filter_backends = SEARCH_ORDER_FILTER
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 ]
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 }
1116 # Default ordering
1117 ordering = 'name'
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 ]
1133class PartDetail(PartMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
1134 """API endpoint for detail view of a single Part object."""
1136 output_options = PartOutputOptions
1138 def update(self, request, *args, **kwargs):
1139 """Custom update functionality for Part instance.
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)
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))
1150 self.get_object().set_starred(
1151 request.user, starred, include_variants=False, include_categories=False
1152 )
1154 return response
1157class PartRelatedFilter(FilterSet):
1158 """FilterSet for PartRelated objects."""
1160 class Meta:
1161 """Metaclass options."""
1163 model = PartRelated
1164 fields = ['part_1', 'part_2']
1166 part = rest_filters.ModelChoiceFilter(
1167 queryset=Part.objects.all(), method='filter_part', label=_('Part')
1168 )
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()
1176class PartRelatedMixin:
1177 """Mixin class for PartRelated API endpoints."""
1179 queryset = PartRelated.objects.all()
1180 serializer_class = part_serializers.PartRelationSerializer
1182 def get_queryset(self, *args, **kwargs):
1183 """Return an annotated queryset for the PartRelatedDetail endpoint."""
1184 queryset = super().get_queryset(*args, **kwargs)
1186 queryset = queryset.prefetch_related('part_1', 'part_2')
1188 return queryset
1191class PartRelatedList(PartRelatedMixin, ListCreateAPI):
1192 """API endpoint for accessing a list of PartRelated objects."""
1194 filterset_class = PartRelatedFilter
1195 filter_backends = SEARCH_ORDER_FILTER
1197 search_fields = ['part_1__name', 'part_2__name']
1200class PartRelatedDetail(PartRelatedMixin, RetrieveUpdateDestroyAPI):
1201 """API endpoint for accessing detail view of a PartRelated object."""
1204class PartStocktakeFilter(FilterSet):
1205 """Custom filter for the PartStocktakeList endpoint."""
1207 class Meta:
1208 """Metaclass options."""
1210 model = PartStocktake
1211 fields = ['part']
1214class PartStocktakeMixin:
1215 """Mixin class for PartStocktake API endpoints."""
1217 queryset = PartStocktake.objects.all().prefetch_related('part')
1218 serializer_class = part_serializers.PartStocktakeSerializer
1221class PartStocktakeList(
1222 PartStocktakeMixin, DataExportViewMixin, BulkDeleteMixin, ListCreateAPI
1223):
1224 """API endpoint for listing part stocktake information."""
1226 filterset_class = PartStocktakeFilter
1228 def get_serializer_context(self):
1229 """Extend serializer context data."""
1230 context = super().get_serializer_context()
1231 context['request'] = self.request
1233 return context
1235 filter_backends = ORDER_FILTER
1237 ordering_fields = ['part', 'item_count', 'quantity', 'date', 'user', 'pk']
1239 # Reverse date ordering by default
1240 ordering = '-pk'
1243class PartStocktakeDetail(PartStocktakeMixin, RetrieveUpdateDestroyAPI):
1244 """Detail API endpoint for a single PartStocktake instance.
1246 Note: Only staff (admin) users can access this endpoint.
1247 """
1250class PartStocktakeGenerate(CreateAPI):
1251 """API endpoint for generating a PartStocktake instance."""
1253 queryset = PartStocktake.objects.all()
1254 serializer_class = part_serializers.PartStocktakeGenerateSerializer
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
1261 serializer = self.get_serializer(data=request.data)
1262 serializer.is_valid(raise_exception=True)
1264 data = serializer.validated_data
1266 part = data.get('part', None)
1267 category = data.get('category', None)
1268 location = data.get('location', None)
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
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 )
1289 if report_output:
1290 report_output.refresh_from_db()
1292 result = {
1293 'category': category,
1294 'location': location,
1295 'part': part,
1296 'output': report_output,
1297 }
1299 output_serializer = part_serializers.PartStocktakeGenerateSerializer(result)
1301 return Response(output_serializer.data)
1304class BomFilter(FilterSet):
1305 """Custom filters for the BOM list."""
1307 class Meta:
1308 """Metaclass options."""
1310 model = BomItem
1311 fields = ['optional', 'consumable', 'inherited', 'allow_variants', 'validated']
1313 # Filters for linked 'part'
1314 part_active = rest_filters.BooleanFilter(
1315 label=_('Assembly part is active'), field_name='part__active'
1316 )
1318 part_trackable = rest_filters.BooleanFilter(
1319 label=_('Assembly part is trackable'), field_name='part__trackable'
1320 )
1322 part_testable = rest_filters.BooleanFilter(
1323 label=_('Assembly part is testable'), field_name='part__testable'
1324 )
1326 part_locked = rest_filters.BooleanFilter(
1327 label=_('Assembly part is locked'), field_name='part__locked'
1328 )
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 )
1335 sub_part_trackable = rest_filters.BooleanFilter(
1336 label=_('Component part is trackable'), field_name='sub_part__trackable'
1337 )
1339 sub_part_testable = rest_filters.BooleanFilter(
1340 label=_('Component part is testable'), field_name='sub_part__testable'
1341 )
1343 sub_part_assembly = rest_filters.BooleanFilter(
1344 label=_('Component part is an assembly'), field_name='sub_part__assembly'
1345 )
1347 sub_part_virtual = rest_filters.BooleanFilter(
1348 label=_('Component part is virtual'), field_name='sub_part__virtual'
1349 )
1351 available_stock = rest_filters.BooleanFilter(
1352 label=_('Has available stock'), method='filter_available_stock'
1353 )
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)
1361 on_order = rest_filters.BooleanFilter(label='On order', method='filter_on_order')
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)
1369 has_pricing = rest_filters.BooleanFilter(
1370 label='Has Pricing', method='filter_has_pricing'
1371 )
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 )
1381 if str2bool(value):
1382 return queryset.exclude(q_a | q_b)
1384 return queryset.filter(q_a | q_b).distinct()
1386 part = rest_filters.ModelChoiceFilter(
1387 queryset=Part.objects.all(), method='filter_part', label=_('Part')
1388 )
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())
1395 category = rest_filters.ModelChoiceFilter(
1396 queryset=PartCategory.objects.all(),
1397 method='filter_category',
1398 label=_('Category'),
1399 )
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)
1406 return queryset.filter(sub_part__category__in=cats)
1408 uses = rest_filters.ModelChoiceFilter(
1409 queryset=Part.objects.all(), method='filter_uses', label=_('Uses')
1410 )
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())
1418class BomMixin(SerializerContextMixin):
1419 """Mixin class for BomItem API endpoints."""
1421 serializer_class = part_serializers.BomItemSerializer
1422 queryset = BomItem.objects.all()
1424 def get_queryset(self, *args, **kwargs):
1425 """Return the queryset object for this endpoint."""
1426 queryset = super().get_queryset(*args, **kwargs)
1428 queryset = self.get_serializer_class().annotate_queryset(queryset)
1430 return queryset
1433class BomOutputOptions(OutputConfiguration):
1434 """Output options for BOM endpoints."""
1436 OPTIONS = [
1437 InvenTreeOutputOption('can_build', default=True),
1438 InvenTreeOutputOption('part_detail'),
1439 InvenTreeOutputOption('sub_part_detail'),
1440 InvenTreeOutputOption('substitutes'),
1441 InvenTreeOutputOption('pricing'),
1442 ]
1445class BomList(
1446 BomMixin,
1447 BulkUpdateMixin,
1448 DataExportViewMixin,
1449 OutputOptionsMixin,
1450 ListCreateDestroyAPIView,
1451):
1452 """API endpoint for accessing a list of BomItem objects.
1454 - GET: Return list of BomItem objects
1455 - POST: Create a new BomItem object
1456 """
1458 output_options = BomOutputOptions
1459 filterset_class = BomFilter
1460 filter_backends = SEARCH_ORDER_FILTER
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 ]
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 ]
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 }
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)
1516class BomDetail(BomMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
1517 """API endpoint for detail view of a single BomItem object."""
1519 output_options = BomOutputOptions
1522class BomItemValidate(UpdateAPI):
1523 """API endpoint for validating a BomItem."""
1525 class BomItemValidationSerializer(serializers.Serializer):
1526 """Simple serializer for passing a single boolean field."""
1528 valid = serializers.BooleanField(default=False)
1530 queryset = BomItem.objects.all()
1531 serializer_class = BomItemValidationSerializer
1533 def update(self, request, *args, **kwargs):
1534 """Perform update request."""
1535 partial = kwargs.pop('partial', False)
1537 # Clean up input data
1538 data = self.clean_data(request.data)
1539 valid = data.get('valid', False)
1541 instance = self.get_object()
1543 serializer = self.get_serializer(instance, data=data, partial=partial)
1544 serializer.is_valid(raise_exception=True)
1546 if isinstance(instance, BomItem):
1547 instance.validate_hash(valid)
1549 return Response(serializer.data)
1552class BomItemSubstituteList(ListCreateAPI):
1553 """API endpoint for accessing a list of BomItemSubstitute objects."""
1555 serializer_class = part_serializers.BomItemSubstituteSerializer
1556 queryset = BomItemSubstitute.objects.all()
1558 filter_backends = SEARCH_ORDER_FILTER
1560 filterset_fields = ['part', 'bom_item']
1563class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI):
1564 """API endpoint for detail view of a single BomItemSubstitute object."""
1566 queryset = BomItemSubstitute.objects.all()
1567 serializer_class = part_serializers.BomItemSubstituteSerializer
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]
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]