Coverage for src/backend/InvenTree/part/serializers.py: 79%
514 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"""DRF data serializers for Part app."""
3import os
4from decimal import Decimal
6from django.core.exceptions import ValidationError
7from django.core.validators import MinValueValidator
8from django.db import models, transaction
9from django.db.models import ExpressionWrapper, F, Q
10from django.db.models.functions import Greatest
11from django.urls import reverse_lazy
12from django.utils.translation import gettext_lazy as _
14import structlog
15from djmoney.contrib.exchange.exceptions import MissingRate
16from djmoney.contrib.exchange.models import convert_money
17from drf_spectacular.utils import extend_schema_field
18from rest_framework import serializers
19from sql_util.utils import SubqueryCount
21import common.currency
22import common.filters
23import common.serializers
24import company.models
25import InvenTree.conversion
26import InvenTree.helpers
27import InvenTree.serializers
28import part.filters as part_filters
29import part.helpers as part_helpers
30import stock.models
31import users.models
32from data_exporter.mixins import DataExportSerializerMixin
33from importer.registry import register_importer
34from InvenTree.mixins import DataImportExportSerializerMixin
35from InvenTree.ready import isGeneratingSchema
36from InvenTree.serializers import OptionalField, TreePathSerializer
37from users.serializers import UserSerializer
39from .models import (
40 BomItem,
41 BomItemSubstitute,
42 Part,
43 PartCategory,
44 PartCategoryParameterTemplate,
45 PartInternalPriceBreak,
46 PartPricing,
47 PartRelated,
48 PartSellPriceBreak,
49 PartStar,
50 PartStocktake,
51 PartTestTemplate,
52)
54logger = structlog.get_logger('inventree')
57class CategoryDeleteSerializer(serializers.Serializer):
58 """Serializer for deleting a PartCategory instance."""
60 class Meta:
61 """Metaclass options."""
63 fields = ['delete_child_categories', 'delete_parts']
65 delete_child_categories = serializers.BooleanField(
66 label=_('Delete Subcategories'),
67 help_text=_('Delete all sub-categories contained within this category'),
68 required=True,
69 )
71 delete_parts = serializers.BooleanField(
72 label=_('Delete Parts'),
73 help_text=_('Delete all parts contained within this category'),
74 required=True,
75 )
78@register_importer()
79class CategorySerializer(
80 InvenTree.serializers.FilterableSerializerMixin,
81 DataImportExportSerializerMixin,
82 InvenTree.serializers.InvenTreeModelSerializer,
83):
84 """Serializer for PartCategory."""
86 class Meta:
87 """Metaclass defining serializer fields."""
89 model = PartCategory
90 fields = [
91 'pk',
92 'name',
93 'description',
94 'default_location',
95 'default_keywords',
96 'level',
97 'parent',
98 'part_count',
99 'subcategories',
100 'pathstring',
101 'path',
102 'starred',
103 'structural',
104 'icon',
105 'parent_default_location',
106 # Optional fields
107 'parameters',
108 ]
109 read_only_fields = ['level', 'pathstring']
111 @staticmethod
112 def annotate_queryset(queryset):
113 """Annotate extra information to the queryset."""
114 # Annotate the number of 'parts' which exist in each category (including subcategories!)
115 queryset = queryset.annotate(
116 part_count=part_filters.annotate_category_parts(),
117 subcategories=part_filters.annotate_sub_categories(),
118 )
120 queryset = queryset.annotate(
121 parent_default_location=part_filters.annotate_default_location('parent__')
122 )
124 return queryset
126 parent = serializers.PrimaryKeyRelatedField(
127 queryset=PartCategory.objects.all(),
128 required=False,
129 allow_null=True,
130 label=_('Parent Category'),
131 help_text=_('Parent part category'),
132 )
134 part_count = serializers.IntegerField(
135 read_only=True, allow_null=True, label=_('Parts')
136 )
138 subcategories = serializers.IntegerField(
139 read_only=True, allow_null=True, label=_('Subcategories')
140 )
142 level = serializers.IntegerField(read_only=True)
144 starred = serializers.SerializerMethodField()
146 def get_starred(self, category) -> bool:
147 """Return True if the category is directly "starred" by the current user."""
148 if not self.request or not self.request.user: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 return False
151 # Cache the "starred_categories" list for the current user
152 if not hasattr(self, 'starred_categories'):
153 self.starred_categories = [
154 star.category.pk for star in self.request.user.starred_categories.all()
155 ]
157 return category.pk in self.starred_categories
159 path = OptionalField(
160 serializer_class=TreePathSerializer,
161 serializer_kwargs={
162 'source': 'get_path',
163 'many': True,
164 'read_only': True,
165 'allow_null': True,
166 },
167 default_include=False,
168 filter_name='path_detail',
169 )
171 icon = serializers.CharField(
172 required=False,
173 allow_blank=True,
174 allow_null=True,
175 help_text=_('Icon (optional)'),
176 max_length=100,
177 )
179 parent_default_location = serializers.IntegerField(read_only=True, allow_null=True)
181 parameters = common.filters.enable_parameters_filter()
184class CategoryTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
185 """Serializer for PartCategory tree."""
187 class Meta:
188 """Metaclass defining serializer fields."""
190 model = PartCategory
191 fields = [
192 'pk',
193 'name',
194 'description',
195 'pathstring',
196 'parent',
197 'tree_id',
198 'level',
199 'icon',
200 'structural',
201 'subcategories',
202 ]
204 subcategories = serializers.IntegerField(label=_('Subcategories'), read_only=True)
206 icon = serializers.CharField(
207 required=False, allow_blank=True, help_text=_('Icon (optional)'), max_length=100
208 )
210 @staticmethod
211 def annotate_queryset(queryset):
212 """Annotate the queryset with the number of subcategories."""
213 return queryset.annotate(subcategories=part_filters.annotate_sub_categories())
216@register_importer()
217class PartTestTemplateSerializer(
218 DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeModelSerializer
219):
220 """Serializer for the PartTestTemplate class."""
222 class Meta:
223 """Metaclass defining serializer fields."""
225 model = PartTestTemplate
227 fields = [
228 'pk',
229 'key',
230 'part',
231 'test_name',
232 'description',
233 'enabled',
234 'required',
235 'requires_value',
236 'requires_attachment',
237 'results',
238 'choices',
239 ]
241 key = serializers.CharField(read_only=True)
242 results = serializers.IntegerField(
243 label=_('Results'),
244 help_text=_('Number of results recorded against this template'),
245 read_only=True,
246 )
248 @staticmethod
249 def annotate_queryset(queryset):
250 """Custom query annotations for the PartTestTemplate serializer."""
251 return queryset.annotate(results=SubqueryCount('test_results'))
254@register_importer()
255class PartSalePriceSerializer(
256 InvenTree.serializers.FilterableSerializerMixin,
257 DataImportExportSerializerMixin,
258 InvenTree.serializers.InvenTreeModelSerializer,
259):
260 """Serializer for sale prices for Part model."""
262 no_filters = True
264 class Meta:
265 """Metaclass defining serializer fields."""
267 model = PartSellPriceBreak
268 fields = ['pk', 'part', 'quantity', 'price', 'price_currency']
270 quantity = InvenTree.serializers.InvenTreeDecimalField()
272 price = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
274 price_currency = InvenTree.serializers.InvenTreeCurrencySerializer(
275 help_text=_('Purchase currency of this stock item')
276 )
279class PartInternalPriceSerializer(InvenTree.serializers.InvenTreeModelSerializer):
280 """Serializer for internal prices for Part model."""
282 class Meta:
283 """Metaclass defining serializer fields."""
285 model = PartInternalPriceBreak
286 fields = ['pk', 'part', 'quantity', 'price', 'price_currency']
288 quantity = InvenTree.serializers.InvenTreeDecimalField()
290 price = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
292 price_currency = InvenTree.serializers.InvenTreeCurrencySerializer(
293 help_text=_('Purchase currency of this stock item')
294 )
297class PartThumbSerializer(serializers.Serializer):
298 """Serializer for the 'image' field of the Part model.
300 Used to serve and display existing Part images.
301 """
303 image = InvenTree.serializers.InvenTreeImageSerializerField(read_only=True)
304 count = serializers.IntegerField(read_only=True)
307class PartThumbSerializerUpdate(InvenTree.serializers.InvenTreeModelSerializer):
308 """Serializer for updating Part thumbnail."""
310 class Meta:
311 """Metaclass defining serializer fields."""
313 model = Part
314 fields = ['image']
316 def validate_image(self, value):
317 """Check that file is an image."""
318 validate = InvenTree.helpers.TestIfImage(value)
319 if not validate:
320 raise serializers.ValidationError(_('File is not an image'))
321 return value
323 image = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True)
326class PartBriefSerializer(
327 InvenTree.serializers.FilterableSerializerMixin,
328 InvenTree.serializers.InvenTreeModelSerializer,
329):
330 """Serializer for Part (brief detail)."""
332 class Meta:
333 """Metaclass defining serializer fields."""
335 model = Part
336 fields = [
337 'pk',
338 'IPN',
339 'barcode_hash',
340 'category_default_location',
341 'default_location',
342 'default_expiry',
343 'name',
344 'revision',
345 'full_name',
346 'description',
347 'image',
348 'thumbnail',
349 'active',
350 'locked',
351 'assembly',
352 'component',
353 'minimum_stock',
354 'is_template',
355 'purchaseable',
356 'salable',
357 'testable',
358 'trackable',
359 'virtual',
360 'units',
361 'pricing_min',
362 'pricing_max',
363 ]
364 read_only_fields = ['barcode_hash']
366 category_default_location = serializers.IntegerField(
367 read_only=True, allow_null=True
368 )
370 image = InvenTree.serializers.InvenTreeImageSerializerField(
371 read_only=True, allow_null=True
372 )
373 thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
375 IPN = serializers.CharField(
376 required=False,
377 allow_null=True,
378 help_text=_('Internal Part Number'),
379 max_length=100,
380 )
382 revision = serializers.CharField(
383 required=False, default='', allow_blank=True, allow_null=True, max_length=100
384 )
386 # Pricing fields
387 pricing_min = OptionalField(
388 serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
389 serializer_kwargs={
390 'source': 'pricing_data.overall_min',
391 'allow_null': True,
392 'read_only': True,
393 },
394 default_include=True,
395 filter_name='pricing',
396 )
398 pricing_max = OptionalField(
399 serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
400 serializer_kwargs={
401 'source': 'pricing_data.overall_max',
402 'allow_null': True,
403 'read_only': True,
404 },
405 default_include=True,
406 filter_name='pricing',
407 )
410class DuplicatePartSerializer(serializers.Serializer):
411 """Serializer for specifying options when duplicating a Part.
413 The fields in this serializer control how the Part is duplicated.
414 """
416 class Meta:
417 """Metaclass options."""
419 fields = [
420 'part',
421 'copy_image',
422 'copy_bom',
423 'copy_parameters',
424 'copy_notes',
425 'copy_tests',
426 ]
428 part = serializers.PrimaryKeyRelatedField(
429 queryset=Part.objects.all(),
430 label=_('Original Part'),
431 help_text=_('Select original part to duplicate'),
432 required=True,
433 )
435 copy_image = serializers.BooleanField(
436 label=_('Copy Image'),
437 help_text=_('Copy image from original part'),
438 required=False,
439 default=False,
440 )
442 copy_bom = serializers.BooleanField(
443 label=_('Copy BOM'),
444 help_text=_('Copy bill of materials from original part'),
445 required=False,
446 default=False,
447 )
449 copy_parameters = serializers.BooleanField(
450 label=_('Copy Parameters'),
451 help_text=_('Copy parameter data from original part'),
452 required=False,
453 default=False,
454 )
456 copy_notes = serializers.BooleanField(
457 label=_('Copy Notes'),
458 help_text=_('Copy notes from original part'),
459 required=False,
460 default=True,
461 )
463 copy_tests = serializers.BooleanField(
464 label=_('Copy Tests'),
465 help_text=_('Copy test templates from original part'),
466 required=False,
467 default=False,
468 )
471class InitialStockSerializer(serializers.Serializer):
472 """Serializer for creating initial stock quantity."""
474 class Meta:
475 """Metaclass options."""
477 fields = ['quantity', 'location']
479 quantity = serializers.DecimalField(
480 max_digits=15,
481 decimal_places=5,
482 validators=[MinValueValidator(0)],
483 label=_('Initial Stock Quantity'),
484 help_text=_(
485 'Specify initial stock quantity for this Part. If quantity is zero, no stock is added.'
486 ),
487 required=True,
488 )
490 location = serializers.PrimaryKeyRelatedField(
491 queryset=stock.models.StockLocation.objects.all(),
492 label=_('Initial Stock Location'),
493 help_text=_('Specify initial stock location for this Part'),
494 allow_null=True,
495 required=False,
496 )
499class InitialSupplierSerializer(serializers.Serializer):
500 """Serializer for adding initial supplier / manufacturer information."""
502 class Meta:
503 """Metaclass options."""
505 fields = ['supplier', 'sku', 'manufacturer', 'mpn']
507 supplier = serializers.PrimaryKeyRelatedField(
508 queryset=company.models.Company.objects.all(),
509 label=_('Supplier'),
510 help_text=_('Select supplier (or leave blank to skip)'),
511 allow_null=True,
512 required=False,
513 )
515 sku = serializers.CharField(
516 max_length=100,
517 required=False,
518 allow_blank=True,
519 label=_('SKU'),
520 help_text=_('Supplier stock keeping unit'),
521 )
523 manufacturer = serializers.PrimaryKeyRelatedField(
524 queryset=company.models.Company.objects.all(),
525 label=_('Manufacturer'),
526 help_text=_('Select manufacturer (or leave blank to skip)'),
527 allow_null=True,
528 required=False,
529 )
531 mpn = serializers.CharField(
532 max_length=100,
533 required=False,
534 allow_blank=True,
535 label=_('MPN'),
536 help_text=_('Manufacturer part number'),
537 )
539 def validate_supplier(self, company):
540 """Validation for the provided Supplier."""
541 if company and not company.is_supplier:
542 raise serializers.ValidationError(
543 _('Selected company is not a valid supplier')
544 )
546 return company
548 def validate_manufacturer(self, company):
549 """Validation for the provided Manufacturer."""
550 if company and not company.is_manufacturer:
551 raise serializers.ValidationError(
552 _('Selected company is not a valid manufacturer')
553 )
555 return company
557 def validate(self, data):
558 """Extra validation for this serializer."""
559 if company.models.ManufacturerPart.objects.filter(
560 manufacturer=data.get('manufacturer', None), MPN=data.get('mpn', '')
561 ).exists():
562 raise serializers.ValidationError({
563 'mpn': _('Manufacturer part matching this MPN already exists')
564 })
566 if company.models.SupplierPart.objects.filter(
567 supplier=data.get('supplier', None), SKU=data.get('sku', '')
568 ).exists():
569 raise serializers.ValidationError({
570 'sku': _('Supplier part matching this SKU already exists')
571 })
573 return data
576class DefaultLocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
577 """Brief serializer for a StockLocation object.
579 Defined here, rather than stock.serializers, to negotiate circular imports.
580 """
582 class Meta:
583 """Metaclass options."""
585 import stock.models as stock_models
587 model = stock_models.StockLocation
588 fields = ['pk', 'name', 'pathstring']
591@register_importer()
592class PartSerializer(
593 InvenTree.serializers.FilterableSerializerMixin,
594 DataImportExportSerializerMixin,
595 InvenTree.serializers.NotesFieldMixin,
596 InvenTree.serializers.InvenTreeTaggitSerializer,
597 InvenTree.serializers.InvenTreeModelSerializer,
598):
599 """Serializer for complete detail information of a part.
601 Used when displaying all details of a single component.
602 """
604 import_exclude_fields = ['creation_date', 'creation_user', 'duplicate']
606 class Meta:
607 """Metaclass defining serializer fields."""
609 model = Part
610 partial = True
611 fields = [
612 'active',
613 'assembly',
614 'barcode_hash',
615 'category',
616 'category_detail',
617 'category_path',
618 'category_name',
619 'component',
620 'creation_date',
621 'creation_user',
622 'default_expiry',
623 'default_location',
624 'default_location_detail',
625 'description',
626 'full_name',
627 'image',
628 'existing_image',
629 'IPN',
630 'is_template',
631 'keywords',
632 'link',
633 'locked',
634 'minimum_stock',
635 'maximum_stock',
636 'name',
637 'notes',
638 'parameters',
639 'pk',
640 'purchaseable',
641 'revision',
642 'revision_of',
643 'revision_count',
644 'salable',
645 'starred',
646 'thumbnail',
647 'testable',
648 'trackable',
649 'units',
650 'variant_of',
651 'virtual',
652 'pricing_min',
653 'pricing_max',
654 'pricing_updated',
655 'responsible',
656 'price_breaks',
657 # Annotated fields
658 'allocated_to_build_orders',
659 'allocated_to_sales_orders',
660 'building',
661 'scheduled_to_build',
662 'category_default_location',
663 'in_stock',
664 'ordering',
665 'required_for_build_orders',
666 'required_for_sales_orders',
667 'stock_item_count',
668 'total_in_stock',
669 'external_stock',
670 'unallocated_stock',
671 'variant_stock',
672 # Fields only used for Part creation
673 'duplicate',
674 'initial_stock',
675 'initial_supplier',
676 'copy_category_parameters',
677 'tags',
678 ]
679 read_only_fields = ['barcode_hash', 'creation_date', 'creation_user']
681 def __init__(self, *args, **kwargs):
682 """Custom initialization method for PartSerializer.
684 - Allows us to optionally pass extra fields based on the query.
685 """
686 create = kwargs.pop('create', False)
688 super().__init__(*args, **kwargs)
690 if isGeneratingSchema(): 690 ↛ 691line 690 didn't jump to line 691 because the condition on line 690 was never true
691 return
693 if not create:
694 # These fields are only used for the LIST API endpoint
695 for f in self.skip_create_fields():
696 # Fields required for certain operations, but are not part of the model
697 if f in ['existing_image']:
698 continue
699 self.fields.pop(f, None)
701 def get_api_url(self):
702 """Return the API url associated with this serializer."""
703 return reverse_lazy('api-part-list')
705 def skip_create_fields(self):
706 """Skip these fields when instantiating a new Part instance."""
707 fields = super().skip_create_fields()
709 fields += [
710 'duplicate',
711 'initial_stock',
712 'initial_supplier',
713 'copy_category_parameters',
714 'existing_image',
715 ]
717 return fields
719 @staticmethod
720 def annotate_queryset(queryset):
721 """Add some extra annotations to the queryset.
723 Performing database queries as efficiently as possible, to reduce database trips.
724 """
725 # Annotate with the total number of revisions
726 queryset = queryset.annotate(revision_count=SubqueryCount('revisions'))
728 # Annotate with the total number of stock items
729 queryset = queryset.annotate(stock_item_count=SubqueryCount('stock_items'))
731 # Annotate with the total variant stock quantity
732 variant_query = part_filters.variant_stock_query()
734 queryset = queryset.annotate(
735 variant_stock=part_filters.annotate_variant_quantity(
736 variant_query, reference='quantity'
737 )
738 )
740 # Annotate with the total 'building' quantity
741 queryset = queryset.annotate(
742 building=part_filters.annotate_in_production_quantity()
743 )
745 queryset = queryset.annotate(
746 scheduled_to_build=part_filters.annotate_scheduled_to_build_quantity()
747 )
749 queryset = queryset.annotate(
750 ordering=part_filters.annotate_on_order_quantity(),
751 in_stock=part_filters.annotate_total_stock(),
752 allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(),
753 # NOTE: for now, decided that allocations to Transfer Orders don't reduce available stock
754 # allocated_to_transfer_orders=part_filters.annotate_transfer_order_allocations(),
755 allocated_to_build_orders=part_filters.annotate_build_order_allocations(),
756 )
758 # Annotate the queryset with the 'total_in_stock' quantity
759 # This is the 'in_stock' quantity summed with the 'variant_stock' quantity
760 queryset = queryset.annotate(
761 total_in_stock=ExpressionWrapper(
762 F('in_stock') + F('variant_stock'), output_field=models.DecimalField()
763 )
764 )
766 queryset = queryset.annotate(
767 external_stock=part_filters.annotate_total_stock(
768 filter=Q(location__external=True)
769 )
770 )
772 # Annotate with the total 'available stock' quantity
773 # This is the current stock, minus any allocations
774 queryset = queryset.annotate(
775 unallocated_stock=Greatest(
776 ExpressionWrapper(
777 F('total_in_stock')
778 - F('allocated_to_sales_orders')
779 # NOTE: for now, decided that allocations to Transfer Orders don't reduce available stock
780 # - F('allocated_to_transfer_orders'),
781 - F('allocated_to_build_orders'),
782 output_field=models.DecimalField(),
783 ),
784 Decimal(0),
785 output_field=models.DecimalField(),
786 )
787 )
789 # Annotate with the total 'required for builds' quantity
790 # NOTE: for now, we don't consider transfer orders for required quantities
791 # and they are assumed to operate on stock that already exists.
792 queryset = queryset.annotate(
793 required_for_build_orders=part_filters.annotate_build_order_requirements(),
794 required_for_sales_orders=part_filters.annotate_sales_order_requirements(),
795 )
797 queryset = queryset.annotate(
798 category_default_location=part_filters.annotate_default_location(
799 'category__'
800 )
801 )
803 return queryset
805 def get_starred(self, part) -> bool:
806 """Return "true" if the part is starred by the current user."""
807 if not self.request or not self.request.user: 807 ↛ 808line 807 didn't jump to line 808 because the condition on line 807 was never true
808 return False
810 # Cache the "starred_parts" list for the current user
811 if not hasattr(self, 'starred_parts'): 811 ↛ 816line 811 didn't jump to line 816 because the condition on line 811 was always true
812 self.starred_parts = [
813 star.part.pk for star in self.request.user.starred_parts.all()
814 ]
816 return part.pk in self.starred_parts
818 # Extra detail for the category
819 category_detail = OptionalField(
820 serializer_class=CategorySerializer,
821 serializer_kwargs={
822 'source': 'category',
823 'many': False,
824 'read_only': True,
825 'allow_null': True,
826 },
827 prefetch_fields=['category'],
828 )
830 category_path = OptionalField(
831 serializer_class=TreePathSerializer,
832 serializer_kwargs={
833 'source': 'category.get_path',
834 'many': True,
835 'read_only': True,
836 'allow_null': True,
837 },
838 filter_name='path_detail',
839 prefetch_fields=['category'],
840 )
842 default_location_detail = OptionalField(
843 serializer_class=DefaultLocationSerializer,
844 serializer_kwargs={
845 'source': 'default_location',
846 'many': False,
847 'read_only': True,
848 'allow_null': True,
849 },
850 filter_name='location_detail',
851 prefetch_fields=['default_location'],
852 )
854 category_name = serializers.CharField(
855 source='category.name', read_only=True, label=_('Category Name')
856 )
858 responsible = serializers.PrimaryKeyRelatedField(
859 queryset=users.models.Owner.objects.all(),
860 required=False,
861 allow_null=True,
862 source='responsible_owner',
863 )
865 creation_user = serializers.PrimaryKeyRelatedField(
866 queryset=users.models.User.objects.all(), required=False, allow_null=True
867 )
869 IPN = serializers.CharField(
870 required=False, default='', allow_blank=True, label=_('IPN'), max_length=100
871 )
873 revision = serializers.CharField(
874 required=False, default='', allow_blank=True, allow_null=True, max_length=100
875 )
877 # Annotated fields
878 allocated_to_build_orders = serializers.FloatField(read_only=True, allow_null=True)
879 allocated_to_sales_orders = serializers.FloatField(read_only=True, allow_null=True)
881 building = serializers.FloatField(
882 read_only=True,
883 allow_null=True,
884 label=_('Building'),
885 help_text=_('Quantity of this part currently being in production'),
886 )
888 scheduled_to_build = serializers.FloatField(
889 read_only=True,
890 allow_null=True,
891 label=_('Scheduled to Build'),
892 help_text=_('Outstanding quantity of this part scheduled to be built'),
893 )
895 in_stock = serializers.FloatField(
896 read_only=True, allow_null=True, label=_('In Stock')
897 )
899 ordering = serializers.FloatField(
900 read_only=True, allow_null=True, label=_('On Order')
901 )
903 required_for_build_orders = serializers.IntegerField(
904 read_only=True, allow_null=True
905 )
907 required_for_sales_orders = serializers.IntegerField(
908 read_only=True, allow_null=True
909 )
911 stock_item_count = serializers.IntegerField(
912 read_only=True, allow_null=True, label=_('Stock Items')
913 )
915 revision_count = serializers.IntegerField(
916 read_only=True, allow_null=True, label=_('Revisions')
917 )
919 total_in_stock = serializers.FloatField(
920 read_only=True, allow_null=True, label=_('Total Stock')
921 )
923 external_stock = serializers.FloatField(
924 read_only=True, allow_null=True, label=_('External Stock')
925 )
927 unallocated_stock = serializers.FloatField(
928 read_only=True, allow_null=True, label=_('Unallocated Stock')
929 )
931 category_default_location = serializers.IntegerField(
932 read_only=True, allow_null=True
933 )
935 variant_stock = serializers.FloatField(
936 read_only=True, allow_null=True, label=_('Variant Stock')
937 )
939 minimum_stock = serializers.FloatField(
940 required=False, label=_('Minimum Stock'), default=0
941 )
943 maximum_stock = serializers.FloatField(
944 required=False, label=_('Maximum Stock'), default=0
945 )
947 image = InvenTree.serializers.InvenTreeImageSerializerField(
948 required=False, allow_null=True
949 )
950 thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
951 starred = serializers.SerializerMethodField()
953 # PrimaryKeyRelated fields (Note: enforcing field type here results in much faster queries, somehow...)
954 category = serializers.PrimaryKeyRelatedField(
955 queryset=PartCategory.objects.all(), required=False, allow_null=True
956 )
958 # Pricing fields
959 pricing_min = OptionalField(
960 serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
961 serializer_kwargs={
962 'source': 'pricing_data.overall_min',
963 'allow_null': True,
964 'read_only': True,
965 },
966 default_include=True,
967 filter_name='pricing',
968 )
970 pricing_max = OptionalField(
971 serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
972 serializer_kwargs={
973 'source': 'pricing_data.overall_max',
974 'allow_null': True,
975 'read_only': True,
976 },
977 default_include=True,
978 filter_name='pricing',
979 )
981 pricing_updated = OptionalField(
982 serializer_class=serializers.DateTimeField,
983 serializer_kwargs={
984 'source': 'pricing_data.updated',
985 'allow_null': True,
986 'read_only': True,
987 },
988 default_include=True,
989 filter_name='pricing',
990 )
992 parameters = common.filters.enable_parameters_filter()
994 tags = common.filters.enable_tags_filter()
996 price_breaks = OptionalField(
997 serializer_class=PartSalePriceSerializer,
998 serializer_kwargs={
999 'source': 'salepricebreaks',
1000 'many': True,
1001 'read_only': True,
1002 'allow_null': True,
1003 },
1004 default_include=False,
1005 filter_name='price_breaks',
1006 prefetch_fields=['salepricebreaks'],
1007 )
1009 # Extra fields used only for creation of a new Part instance
1010 duplicate = DuplicatePartSerializer(
1011 label=_('Duplicate Part'),
1012 help_text=_('Copy initial data from another Part'),
1013 write_only=True,
1014 required=False,
1015 )
1017 initial_stock = InitialStockSerializer(
1018 label=_('Initial Stock'),
1019 help_text=_('Create Part with initial stock quantity'),
1020 write_only=True,
1021 required=False,
1022 )
1024 initial_supplier = InitialSupplierSerializer(
1025 label=_('Supplier Information'),
1026 help_text=_('Add initial supplier information for this part'),
1027 write_only=True,
1028 required=False,
1029 )
1031 copy_category_parameters = serializers.BooleanField(
1032 default=True,
1033 required=False,
1034 write_only=True,
1035 label=_('Copy Category Parameters'),
1036 help_text=_('Copy parameter templates from selected part category'),
1037 )
1039 # Allow selection of an existing part image file
1040 existing_image = serializers.CharField(
1041 label=_('Existing Image'),
1042 help_text=_('Filename of an existing part image'),
1043 write_only=True,
1044 required=False,
1045 allow_blank=False,
1046 )
1048 def validate_existing_image(self, img):
1049 """Validate the selected image file."""
1050 if not img: 1050 ↛ 1051line 1050 didn't jump to line 1051 because the condition on line 1050 was never true
1051 return img
1053 img = img.split(os.path.sep)[-1]
1055 # Ensure that the file actually exists
1056 img_path = os.path.join(part_helpers.get_part_image_directory(), img)
1058 if not os.path.exists(img_path) or not os.path.isfile(img_path): 1058 ↛ 1061line 1058 didn't jump to line 1061 because the condition on line 1058 was always true
1059 raise ValidationError(_('Image file does not exist'))
1061 return img
1063 @transaction.atomic
1064 def create(self, validated_data):
1065 """Custom method for creating a new Part instance using this serializer."""
1066 duplicate = validated_data.pop('duplicate', None)
1067 initial_stock = validated_data.pop('initial_stock', None)
1068 initial_supplier = validated_data.pop('initial_supplier', None)
1069 copy_category_parameters = validated_data.pop('copy_category_parameters', False)
1071 instance = super().create(validated_data)
1073 # Save user information
1074 if request := self.context.get('request'): 1074 ↛ 1079line 1074 didn't jump to line 1079 because the condition on line 1074 was always true
1075 instance.creation_user = request.user
1076 instance.save()
1078 # Copy data from original Part
1079 if duplicate: 1079 ↛ 1080line 1079 didn't jump to line 1080 because the condition on line 1079 was never true
1080 original = duplicate['part']
1082 if duplicate.get('copy_bom', False):
1083 instance.copy_bom_from(original)
1085 if duplicate.get('copy_notes', False):
1086 instance.notes = original.notes
1087 instance.save()
1089 if duplicate.get('copy_image', False):
1090 instance.image = original.image
1091 instance.save()
1093 if duplicate.get('copy_parameters', False):
1094 instance.copy_parameters_from(original)
1096 if duplicate.get('copy_tests', False):
1097 instance.copy_tests_from(original)
1099 # Duplicate parameter data from part category (and parents)
1100 if copy_category_parameters and instance.category is not None: 1100 ↛ 1102line 1100 didn't jump to line 1102 because the condition on line 1100 was never true
1101 # Get flattened list of parent categories
1102 instance.copy_category_parameters(instance.category)
1104 # Create initial stock entry
1105 if initial_stock: 1105 ↛ 1106line 1105 didn't jump to line 1106 because the condition on line 1105 was never true
1106 quantity = initial_stock['quantity']
1107 location = initial_stock.get('location', None) or instance.default_location
1109 if quantity > 0:
1110 stockitem = stock.models.StockItem(
1111 part=instance, quantity=quantity, location=location
1112 )
1114 if request := self.context.get('request', None):
1115 stockitem.save(user=request.user)
1117 # Create initial supplier information
1118 if initial_supplier: 1118 ↛ 1119line 1118 didn't jump to line 1119 because the condition on line 1118 was never true
1119 manufacturer = initial_supplier.get('manufacturer', None)
1120 mpn = initial_supplier.get('mpn', '')
1122 if manufacturer and mpn:
1123 manufacturer_part = company.models.ManufacturerPart.objects.create(
1124 part=instance, manufacturer=manufacturer, MPN=mpn
1125 )
1126 else:
1127 manufacturer_part = None
1129 supplier = initial_supplier.get('supplier', None)
1130 sku = initial_supplier.get('sku', '')
1132 if supplier and sku:
1133 company.models.SupplierPart.objects.create(
1134 part=instance,
1135 supplier=supplier,
1136 SKU=sku,
1137 manufacturer_part=manufacturer_part,
1138 )
1140 return instance
1142 def save(self):
1143 """Save the Part instance."""
1144 super().save()
1146 part = self.instance
1147 data = self.validated_data
1149 # TODO: Remove the existing_image field entirely!
1150 existing_image = data.pop('existing_image', None)
1152 if existing_image: 1152 ↛ 1153line 1152 didn't jump to line 1153 because the condition on line 1152 was never true
1153 img_path = os.path.join(part_helpers.PART_IMAGE_DIR, existing_image)
1155 part.image = img_path
1156 part.save()
1158 return self.instance
1161class PartBomValidateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
1162 """Serializer for Part BOM information."""
1164 class Meta:
1165 """Metaclass options."""
1167 model = Part
1168 fields = [
1169 'pk',
1170 'bom_validated',
1171 'bom_checksum',
1172 'bom_checked_by',
1173 'bom_checked_by_detail',
1174 'bom_checked_date',
1175 'valid',
1176 ]
1178 read_only_fields = [
1179 'bom_validated',
1180 'bom_checksum',
1181 'bom_checked_by',
1182 'bom_checked_by_detail',
1183 'bom_checked_date',
1184 ]
1186 valid = serializers.BooleanField(
1187 write_only=True,
1188 default=False,
1189 required=False,
1190 label=_('Valid'),
1191 help_text=_('Validate entire Bill of Materials'),
1192 )
1194 bom_checked_by_detail = UserSerializer(
1195 source='bom_checked_by', many=False, read_only=True, allow_null=True
1196 )
1199class PartRequirementsSerializer(InvenTree.serializers.InvenTreeModelSerializer):
1200 """Serializer for Part requirements."""
1202 class Meta:
1203 """Metaclass options."""
1205 model = Part
1206 fields = [
1207 'total_stock',
1208 'unallocated_stock',
1209 'can_build',
1210 'ordering',
1211 'building',
1212 'scheduled_to_build',
1213 'required_for_build_orders',
1214 'allocated_to_build_orders',
1215 'required_for_sales_orders',
1216 'allocated_to_sales_orders',
1217 ]
1219 total_stock = serializers.FloatField(read_only=True, label=_('Total Stock'))
1221 unallocated_stock = serializers.FloatField(
1222 source='available_stock', read_only=True, label=_('Available Stock')
1223 )
1225 can_build = serializers.FloatField(read_only=True, label=_('Can Build'))
1227 ordering = serializers.FloatField(
1228 source='on_order', read_only=True, label=_('On Order')
1229 )
1231 building = serializers.FloatField(
1232 read_only=True, label=_('In Production'), source='quantity_in_production'
1233 )
1235 scheduled_to_build = serializers.IntegerField(
1236 read_only=True, label=_('Scheduled to Build'), source='quantity_being_built'
1237 )
1239 required_for_build_orders = serializers.FloatField(
1240 source='required_build_order_quantity',
1241 read_only=True,
1242 label=_('Required for Build Orders'),
1243 )
1245 allocated_to_build_orders = serializers.FloatField(
1246 read_only=True,
1247 label=_('Allocated to Build Orders'),
1248 source='build_order_allocation_count',
1249 )
1251 required_for_sales_orders = serializers.FloatField(
1252 source='required_sales_order_quantity',
1253 read_only=True,
1254 label=_('Required for Sales Orders'),
1255 )
1257 allocated_to_sales_orders = serializers.SerializerMethodField(
1258 read_only=True, label=_('Allocated to Sales Orders')
1259 )
1261 def get_allocated_to_sales_orders(self, part) -> float:
1262 """Return the allocated sales order quantity."""
1263 return part.sales_order_allocation_count(include_variants=True, pending=True)
1266class PartStocktakeSerializer(
1267 InvenTree.serializers.FilterableSerializerMixin,
1268 DataExportSerializerMixin,
1269 InvenTree.serializers.InvenTreeModelSerializer,
1270):
1271 """Serializer for the PartStocktake model."""
1273 class Meta:
1274 """Metaclass options."""
1276 model = PartStocktake
1277 fields = [
1278 'pk',
1279 'part',
1280 'part_name',
1281 'part_ipn',
1282 'part_description',
1283 'date',
1284 'item_count',
1285 'quantity',
1286 'cost_min',
1287 'cost_min_currency',
1288 'cost_max',
1289 'cost_max_currency',
1290 # Optional detail fields
1291 'part_detail',
1292 ]
1294 read_only_fields = ['date', 'user']
1296 def __init__(self, *args, **kwargs):
1297 """Custom initialization for PartStocktakeSerializer."""
1298 exclude_pk = kwargs.pop('exclude_pk', False)
1300 super().__init__(*args, **kwargs)
1302 if exclude_pk: 1302 ↛ 1303line 1302 didn't jump to line 1303 because the condition on line 1302 was never true
1303 self.fields.pop('pk', None)
1305 quantity = serializers.FloatField()
1307 cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
1308 cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
1310 cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
1311 cost_max_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
1313 part_name = serializers.CharField(
1314 source='part.name', read_only=True, label=_('Part Name')
1315 )
1317 part_ipn = serializers.CharField(
1318 source='part.IPN', read_only=True, allow_null=True, label=_('Part IPN')
1319 )
1321 part_description = serializers.CharField(
1322 source='part.description',
1323 read_only=True,
1324 allow_null=True,
1325 label=_('Part Description'),
1326 )
1328 part_detail = OptionalField(
1329 serializer_class=PartBriefSerializer,
1330 serializer_kwargs={
1331 'source': 'part',
1332 'read_only': True,
1333 'allow_null': True,
1334 'many': False,
1335 'pricing': False,
1336 },
1337 default_include=False,
1338 )
1340 def save(self):
1341 """Called when this serializer is saved."""
1342 data = self.validated_data
1344 # Add in user information automatically
1345 request = self.context.get('request')
1346 data['user'] = request.user if request else None
1347 return super().save()
1350class PartStocktakeGenerateSerializer(serializers.Serializer):
1351 """Serializer for generating PartStocktake entries."""
1353 class Meta:
1354 """Metaclass options."""
1356 fields = [
1357 'part',
1358 'category',
1359 'location',
1360 'generate_entry',
1361 'generate_report',
1362 'output',
1363 ]
1365 part = serializers.PrimaryKeyRelatedField(
1366 queryset=Part.objects.all(),
1367 label=_('Part'),
1368 help_text=_(
1369 'Select a part to generate stocktake information for that part (and any variant parts)'
1370 ),
1371 required=False,
1372 allow_null=True,
1373 )
1375 category = serializers.PrimaryKeyRelatedField(
1376 queryset=PartCategory.objects.all(),
1377 label=_('Category'),
1378 help_text=_(
1379 'Select a category to include all parts within that category (and subcategories)'
1380 ),
1381 required=False,
1382 allow_null=True,
1383 )
1385 location = serializers.PrimaryKeyRelatedField(
1386 queryset=stock.models.StockLocation.objects.all(),
1387 label=_('Location'),
1388 help_text=_(
1389 'Select a location to include all parts with stock in that location (including sub-locations)'
1390 ),
1391 required=False,
1392 allow_null=True,
1393 )
1395 generate_entry = serializers.BooleanField(
1396 label=_('Generate Stocktake Entries'),
1397 help_text=_('Save stocktake entries for the selected parts'),
1398 write_only=True,
1399 required=False,
1400 default=False,
1401 )
1403 generate_report = serializers.BooleanField(
1404 label=_('Generate Report'),
1405 help_text=_('Generate a stocktake report for the selected parts'),
1406 write_only=True,
1407 required=False,
1408 default=False,
1409 )
1411 output = common.serializers.DataOutputSerializer(
1412 read_only=True, many=False, label=_('Output')
1413 )
1416@extend_schema_field(
1417 serializers.CharField(
1418 help_text=_('Select currency from available options')
1419 + '\n\n'
1420 + '\n'.join(
1421 f'* `{value}` - {label}'
1422 for value, label in common.currency.currency_code_mappings()
1423 )
1424 + "\n\nOther valid currencies may be found in the 'CURRENCY_CODES' global setting."
1425 )
1426)
1427class PartPricingCurrencySerializer(serializers.ChoiceField):
1428 """Serializer to allow annotating the schema to use String on currency fields."""
1431class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
1432 """Serializer for Part pricing information."""
1434 class Meta:
1435 """Metaclass defining serializer fields."""
1437 model = PartPricing
1438 fields = [
1439 'currency',
1440 'updated',
1441 'scheduled_for_update',
1442 'bom_cost_min',
1443 'bom_cost_max',
1444 'purchase_cost_min',
1445 'purchase_cost_max',
1446 'internal_cost_min',
1447 'internal_cost_max',
1448 'supplier_price_min',
1449 'supplier_price_max',
1450 'variant_cost_min',
1451 'variant_cost_max',
1452 'override_min',
1453 'override_min_currency',
1454 'override_max',
1455 'override_max_currency',
1456 'overall_min',
1457 'overall_max',
1458 'sale_price_min',
1459 'sale_price_max',
1460 'sale_history_min',
1461 'sale_history_max',
1462 'update',
1463 ]
1465 currency = serializers.CharField(allow_null=True, read_only=True)
1467 updated = serializers.DateTimeField(allow_null=True, read_only=True)
1469 scheduled_for_update = serializers.BooleanField(read_only=True)
1471 # Custom serializers
1472 bom_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(
1473 allow_null=True, read_only=True
1474 )
1475 bom_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(
1476 allow_null=True, read_only=True
1477 )
1479 purchase_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(
1480 allow_null=True, read_only=True
1481 )
1482 purchase_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(
1483 allow_null=True, read_only=True
1484 )
1486 internal_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(
1487 allow_null=True, read_only=True
1488 )
1489 internal_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(
1490 allow_null=True, read_only=True
1491 )
1493 supplier_price_min = InvenTree.serializers.InvenTreeMoneySerializer(
1494 allow_null=True, read_only=True
1495 )
1496 supplier_price_max = InvenTree.serializers.InvenTreeMoneySerializer(
1497 allow_null=True, read_only=True
1498 )
1500 variant_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(
1501 allow_null=True, read_only=True
1502 )
1503 variant_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(
1504 allow_null=True, read_only=True
1505 )
1507 override_min = InvenTree.serializers.InvenTreeMoneySerializer(
1508 label=_('Minimum Price'),
1509 help_text=_('Override calculated value for minimum price'),
1510 allow_null=True,
1511 read_only=False,
1512 required=False,
1513 )
1515 override_min_currency = PartPricingCurrencySerializer(
1516 label=_('Minimum price currency'),
1517 read_only=False,
1518 required=False,
1519 choices=common.currency.currency_code_mappings(),
1520 )
1522 override_max = InvenTree.serializers.InvenTreeMoneySerializer(
1523 label=_('Maximum Price'),
1524 help_text=_('Override calculated value for maximum price'),
1525 allow_null=True,
1526 read_only=False,
1527 required=False,
1528 )
1530 override_max_currency = PartPricingCurrencySerializer(
1531 label=_('Maximum price currency'),
1532 read_only=False,
1533 required=False,
1534 choices=common.currency.currency_code_mappings(),
1535 )
1537 overall_min = InvenTree.serializers.InvenTreeMoneySerializer(
1538 allow_null=True, read_only=True
1539 )
1540 overall_max = InvenTree.serializers.InvenTreeMoneySerializer(
1541 allow_null=True, read_only=True
1542 )
1544 sale_price_min = InvenTree.serializers.InvenTreeMoneySerializer(
1545 allow_null=True, read_only=True
1546 )
1547 sale_price_max = InvenTree.serializers.InvenTreeMoneySerializer(
1548 allow_null=True, read_only=True
1549 )
1551 sale_history_min = InvenTree.serializers.InvenTreeMoneySerializer(
1552 allow_null=True, read_only=True
1553 )
1554 sale_history_max = InvenTree.serializers.InvenTreeMoneySerializer(
1555 allow_null=True, read_only=True
1556 )
1558 update = serializers.BooleanField(
1559 write_only=True,
1560 label=_('Update'),
1561 help_text=_('Update pricing for this part'),
1562 default=False,
1563 required=False,
1564 allow_null=True,
1565 )
1567 def validate(self, data):
1568 """Validate supplied pricing data."""
1569 super().validate(data)
1571 # Check that override_min is not greater than override_max
1572 override_min = data.get('override_min', None)
1573 override_max = data.get('override_max', None)
1575 default_currency = common.currency.currency_code_default()
1577 if override_min is not None and override_max is not None: 1577 ↛ 1578line 1577 didn't jump to line 1578 because the condition on line 1577 was never true
1578 try:
1579 override_min = convert_money(override_min, default_currency)
1580 override_max = convert_money(override_max, default_currency)
1581 except MissingRate:
1582 raise ValidationError(
1583 _(
1584 f'Could not convert from provided currencies to {default_currency}'
1585 )
1586 )
1588 if override_min > override_max:
1589 raise ValidationError({
1590 'override_min': _(
1591 'Minimum price must not be greater than maximum price'
1592 ),
1593 'override_max': _(
1594 'Maximum price must not be less than minimum price'
1595 ),
1596 })
1598 return data
1600 def save(self):
1601 """Called when the serializer is saved."""
1602 super().save()
1604 data = self.validated_data
1606 if data.get('update', False): 1606 ↛ 1608line 1606 didn't jump to line 1608 because the condition on line 1606 was never true
1607 # Update part pricing
1608 pricing = self.instance
1609 pricing.update_pricing()
1612class PartSerialNumberSerializer(InvenTree.serializers.InvenTreeModelSerializer):
1613 """Serializer for Part serial number information."""
1615 class Meta:
1616 """Metaclass defining serializer fields."""
1618 model = Part
1619 fields = ['latest', 'next']
1621 latest = serializers.CharField(
1622 source='get_latest_serial_number', read_only=True, allow_null=True
1623 )
1624 next = serializers.CharField(source='get_next_serial_number', read_only=True)
1627class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
1628 """Serializer for a PartRelated model."""
1630 class Meta:
1631 """Metaclass defining serializer fields."""
1633 model = PartRelated
1634 fields = ['pk', 'part_1', 'part_1_detail', 'part_2', 'part_2_detail', 'note']
1636 part_1_detail = PartSerializer(source='part_1', read_only=True, many=False)
1637 part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
1640class PartStarSerializer(InvenTree.serializers.InvenTreeModelSerializer):
1641 """Serializer for a PartStar object."""
1643 class Meta:
1644 """Metaclass defining serializer fields."""
1646 model = PartStar
1647 fields = ['pk', 'part', 'partname', 'user', 'username']
1649 partname = serializers.CharField(source='part.full_name', read_only=True)
1650 username = serializers.CharField(source='user.username', read_only=True)
1653class BomItemSubstituteSerializer(InvenTree.serializers.InvenTreeModelSerializer):
1654 """Serializer for the BomItemSubstitute class."""
1656 class Meta:
1657 """Metaclass defining serializer fields."""
1659 model = BomItemSubstitute
1660 fields = ['pk', 'bom_item', 'part', 'part_detail']
1661 # list_serializer_class = FilterableListSerializer
1663 part_detail = PartBriefSerializer(
1664 source='part', read_only=True, many=False, pricing=False
1665 )
1668@register_importer()
1669class BomItemSerializer(
1670 InvenTree.serializers.FilterableSerializerMixin,
1671 DataImportExportSerializerMixin,
1672 InvenTree.serializers.InvenTreeModelSerializer,
1673):
1674 """Serializer for BomItem object."""
1676 import_exclude_fields = ['quantity', 'validated', 'substitutes']
1678 export_exclude_fields = ['substitutes']
1680 export_child_fields = [
1681 'sub_part_detail.name',
1682 'sub_part_detail.IPN',
1683 'sub_part_detail.description',
1684 ]
1686 class Meta:
1687 """Metaclass defining serializer fields."""
1689 model = BomItem
1690 fields = [
1691 'part',
1692 'sub_part',
1693 'reference',
1694 'raw_amount',
1695 'quantity',
1696 'allow_variants',
1697 'inherited',
1698 'optional',
1699 'consumable',
1700 'setup_quantity',
1701 'attrition',
1702 'rounding_multiple',
1703 'note',
1704 'pk',
1705 'pricing_min',
1706 'pricing_max',
1707 'pricing_min_total',
1708 'pricing_max_total',
1709 'pricing_updated',
1710 'substitutes',
1711 'validated',
1712 # Annotated fields describing available quantity
1713 'available_stock',
1714 'available_substitute_stock',
1715 'available_variant_stock',
1716 'external_stock',
1717 # Annotated field describing quantity on order
1718 'on_order',
1719 # Annotated field describing quantity being built
1720 'building',
1721 # Annotate the total potential quantity we can build
1722 'can_build',
1723 # Optional detail fields
1724 'part_detail',
1725 'sub_part_detail',
1726 'category_detail',
1727 ]
1729 raw_amount = serializers.CharField(
1730 label=_('Amount'),
1731 help_text=_('Amount required for this item (can include units)'),
1732 required=False,
1733 )
1735 quantity = InvenTree.serializers.InvenTreeDecimalField(required=False)
1737 setup_quantity = InvenTree.serializers.InvenTreeDecimalField(required=False)
1739 attrition = InvenTree.serializers.InvenTreeDecimalField(required=False)
1741 rounding_multiple = InvenTree.serializers.InvenTreeDecimalField(
1742 required=False, allow_null=True
1743 )
1745 part = serializers.PrimaryKeyRelatedField(
1746 queryset=Part.objects.filter(assembly=True),
1747 label=_('Assembly'),
1748 help_text=_('Select the parent assembly'),
1749 )
1751 substitutes = OptionalField(
1752 serializer_class=BomItemSubstituteSerializer,
1753 serializer_kwargs={
1754 'many': True,
1755 'read_only': True,
1756 'allow_null': True,
1757 'required': False,
1758 },
1759 default_include=False,
1760 filter_name='substitutes',
1761 prefetch_fields=[
1762 'substitutes',
1763 'substitutes__part',
1764 'substitutes__part__stock_items',
1765 'substitutes__part__pricing_data',
1766 ],
1767 )
1769 part_detail = OptionalField(
1770 serializer_class=PartBriefSerializer,
1771 serializer_kwargs={
1772 'source': 'part',
1773 'label': _('Assembly'),
1774 'many': False,
1775 'read_only': True,
1776 'allow_null': True,
1777 },
1778 )
1780 sub_part = serializers.PrimaryKeyRelatedField(
1781 queryset=Part.objects.filter(component=True),
1782 label=_('Component'),
1783 help_text=_('Select the component part'),
1784 )
1786 sub_part_detail = OptionalField(
1787 serializer_class=PartBriefSerializer,
1788 serializer_kwargs={
1789 'source': 'sub_part',
1790 'label': _('Component'),
1791 'many': False,
1792 'read_only': True,
1793 'allow_null': True,
1794 },
1795 default_include=True,
1796 )
1798 category_detail = OptionalField(
1799 serializer_class=CategorySerializer,
1800 serializer_kwargs={
1801 'source': 'sub_part.category',
1802 'label': _('Category'),
1803 'many': False,
1804 'read_only': True,
1805 'allow_null': True,
1806 },
1807 default_include=False,
1808 )
1810 on_order = serializers.FloatField(
1811 label=_('On Order'), read_only=True, allow_null=True
1812 )
1814 building = serializers.FloatField(
1815 label=_('In Production'), read_only=True, allow_null=True
1816 )
1818 can_build = OptionalField(
1819 serializer_class=serializers.FloatField,
1820 serializer_kwargs={
1821 'label': _('Can Build'),
1822 'read_only': True,
1823 'allow_null': True,
1824 },
1825 default_include=True,
1826 )
1828 # Cached pricing fields
1829 pricing_min = OptionalField(
1830 serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
1831 serializer_kwargs={
1832 'source': 'sub_part.pricing_data.overall_min',
1833 'allow_null': True,
1834 'read_only': True,
1835 },
1836 default_include=True,
1837 filter_name='pricing',
1838 )
1840 pricing_max = OptionalField(
1841 serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
1842 serializer_kwargs={
1843 'source': 'sub_part.pricing_data.overall_max',
1844 'allow_null': True,
1845 'read_only': True,
1846 },
1847 default_include=True,
1848 filter_name='pricing',
1849 )
1851 pricing_min_total = OptionalField(
1852 serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
1853 serializer_kwargs={'allow_null': True, 'read_only': True},
1854 default_include=True,
1855 filter_name='pricing',
1856 )
1858 pricing_max_total = OptionalField(
1859 serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
1860 serializer_kwargs={'allow_null': True, 'read_only': True},
1861 default_include=True,
1862 filter_name='pricing',
1863 )
1865 pricing_updated = OptionalField(
1866 serializer_class=serializers.DateTimeField,
1867 serializer_kwargs={
1868 'source': 'sub_part.pricing_data.updated',
1869 'allow_null': True,
1870 'read_only': True,
1871 },
1872 default_include=True,
1873 filter_name='pricing',
1874 )
1876 # Annotated fields for available stock
1877 available_stock = serializers.FloatField(
1878 label=_('Available Stock'), read_only=True, allow_null=True
1879 )
1881 available_substitute_stock = serializers.FloatField(read_only=True, allow_null=True)
1882 available_variant_stock = serializers.FloatField(read_only=True, allow_null=True)
1884 external_stock = serializers.FloatField(read_only=True, allow_null=True)
1886 def validate(self, data):
1887 """Validate the supplied data.
1889 Here, for legacy support, we intercept the 'quantity' field
1890 (if the 'raw_amount' field is not provided)
1891 """
1892 qty = data.pop('quantity', None)
1894 if 'raw_amount' not in data and qty is not None:
1895 data['raw_amount'] = qty
1897 # Check the raw_amount field is valid (this will raise a ValidationError if not)
1898 if raw_amount := data.get('raw_amount', None):
1899 try:
1900 # Check that the value is acceptable to the unit registry
1901 InvenTree.conversion.convert_value(raw_amount)
1902 except Exception:
1903 raise ValidationError({'raw_amount': _('Invalid quantity format')})
1905 return super().validate(data)
1907 @staticmethod
1908 def annotate_queryset(queryset):
1909 """Annotate the BomItem queryset with extra information.
1911 Annotations:
1912 available_stock: The amount of stock available for the sub_part Part object
1913 """
1914 """
1915 Construct an "available stock" quantity:
1916 available_stock = total_stock - build_order_allocations - sales_order_allocations
1917 """
1919 # Prefetch related fields
1920 queryset = queryset.prefetch_related(
1921 'part',
1922 'part__category',
1923 'part__stock_items',
1924 'sub_part',
1925 'sub_part__builds',
1926 'sub_part__category',
1927 'sub_part__pricing_data',
1928 'sub_part__stock_items',
1929 'sub_part__stock_items__allocations',
1930 'sub_part__stock_items__sales_order_allocations',
1931 'sub_part__stock_items__transfer_order_allocations',
1932 )
1934 # Annotate with the 'total pricing' information based on unit pricing and quantity
1935 queryset = queryset.annotate(
1936 pricing_min_total=ExpressionWrapper(
1937 F('quantity') * F('sub_part__pricing_data__overall_min'),
1938 output_field=models.DecimalField(),
1939 ),
1940 pricing_max_total=ExpressionWrapper(
1941 F('quantity') * F('sub_part__pricing_data__overall_max'),
1942 output_field=models.DecimalField(),
1943 ),
1944 )
1946 ref = 'sub_part__'
1948 # Annotate with the total "on order" amount for the sub-part
1949 queryset = queryset.annotate(
1950 on_order=part_filters.annotate_on_order_quantity(ref)
1951 )
1953 # Annotate with the total "building" amount for the sub-part
1954 queryset = queryset.annotate(
1955 building=part_filters.annotate_in_production_quantity(ref)
1956 )
1958 # Calculate 'external_stock'
1959 queryset = queryset.annotate(
1960 external_stock=part_filters.annotate_total_stock(
1961 reference=ref, filter=Q(location__external=True)
1962 )
1963 )
1965 # Annotate available stock and "can_build" quantities
1966 queryset = part_filters.annotate_bom_item_can_build(queryset)
1968 return queryset
1971@register_importer()
1972class CategoryParameterTemplateSerializer(
1973 InvenTree.serializers.FilterableSerializerMixin,
1974 DataImportExportSerializerMixin,
1975 InvenTree.serializers.InvenTreeModelSerializer,
1976):
1977 """Serializer for the PartCategoryParameterTemplate model."""
1979 class Meta:
1980 """Metaclass defining serializer fields."""
1982 model = PartCategoryParameterTemplate
1983 fields = [
1984 'pk',
1985 'category',
1986 'category_detail',
1987 'template',
1988 'template_detail',
1989 'default_value',
1990 ]
1992 template_detail = OptionalField(
1993 serializer_class=common.serializers.ParameterTemplateSerializer,
1994 serializer_kwargs={'source': 'template', 'many': False, 'read_only': True},
1995 default_include=True,
1996 prefetch_fields=['template'],
1997 )
1999 category_detail = OptionalField(
2000 serializer_class=CategorySerializer,
2001 serializer_kwargs={
2002 'source': 'category',
2003 'many': False,
2004 'read_only': True,
2005 'allow_null': True,
2006 },
2007 default_include=True,
2008 prefetch_fields=['category'],
2009 )
2012class PartCopyBOMSerializer(serializers.Serializer):
2013 """Serializer for copying a BOM from another part."""
2015 class Meta:
2016 """Metaclass defining serializer fields."""
2018 fields = [
2019 'part',
2020 'remove_existing',
2021 'copy_substitutes',
2022 'include_inherited',
2023 'skip_invalid',
2024 ]
2026 part = serializers.PrimaryKeyRelatedField(
2027 queryset=Part.objects.all(),
2028 many=False,
2029 required=True,
2030 allow_null=False,
2031 label=_('Part'),
2032 help_text=_('Select part to copy BOM from'),
2033 )
2035 def validate_part(self, part):
2036 """Check that a 'valid' part was selected."""
2037 return part
2039 remove_existing = serializers.BooleanField(
2040 label=_('Remove Existing Data'),
2041 help_text=_('Remove existing BOM items before copying'),
2042 default=True,
2043 )
2045 include_inherited = serializers.BooleanField(
2046 label=_('Include Inherited'),
2047 help_text=_('Include BOM items which are inherited from templated parts'),
2048 default=False,
2049 )
2051 skip_invalid = serializers.BooleanField(
2052 label=_('Skip Invalid Rows'),
2053 help_text=_('Enable this option to skip invalid rows'),
2054 default=False,
2055 )
2057 copy_substitutes = serializers.BooleanField(
2058 label=_('Copy Substitute Parts'),
2059 help_text=_('Copy substitute parts when duplicate BOM items'),
2060 default=True,
2061 )
2063 def save(self):
2064 """Actually duplicate the BOM."""
2065 base_part = self.context['part']
2067 data = self.validated_data
2069 base_part.copy_bom_from(
2070 data['part'],
2071 clear=data.get('remove_existing', True),
2072 skip_invalid=data.get('skip_invalid', False),
2073 include_inherited=data.get('include_inherited', False),
2074 copy_substitutes=data.get('copy_substitutes', True),
2075 )