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

1"""DRF data serializers for Part app.""" 

2 

3import os 

4from decimal import Decimal 

5 

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 _ 

13 

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 

20 

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 

38 

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) 

53 

54logger = structlog.get_logger('inventree') 

55 

56 

57class CategoryDeleteSerializer(serializers.Serializer): 

58 """Serializer for deleting a PartCategory instance.""" 

59 

60 class Meta: 

61 """Metaclass options.""" 

62 

63 fields = ['delete_child_categories', 'delete_parts'] 

64 

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 ) 

70 

71 delete_parts = serializers.BooleanField( 

72 label=_('Delete Parts'), 

73 help_text=_('Delete all parts contained within this category'), 

74 required=True, 

75 ) 

76 

77 

78@register_importer() 

79class CategorySerializer( 

80 InvenTree.serializers.FilterableSerializerMixin, 

81 DataImportExportSerializerMixin, 

82 InvenTree.serializers.InvenTreeModelSerializer, 

83): 

84 """Serializer for PartCategory.""" 

85 

86 class Meta: 

87 """Metaclass defining serializer fields.""" 

88 

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'] 

110 

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 ) 

119 

120 queryset = queryset.annotate( 

121 parent_default_location=part_filters.annotate_default_location('parent__') 

122 ) 

123 

124 return queryset 

125 

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 ) 

133 

134 part_count = serializers.IntegerField( 

135 read_only=True, allow_null=True, label=_('Parts') 

136 ) 

137 

138 subcategories = serializers.IntegerField( 

139 read_only=True, allow_null=True, label=_('Subcategories') 

140 ) 

141 

142 level = serializers.IntegerField(read_only=True) 

143 

144 starred = serializers.SerializerMethodField() 

145 

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 

150 

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 ] 

156 

157 return category.pk in self.starred_categories 

158 

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 ) 

170 

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 ) 

178 

179 parent_default_location = serializers.IntegerField(read_only=True, allow_null=True) 

180 

181 parameters = common.filters.enable_parameters_filter() 

182 

183 

184class CategoryTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

185 """Serializer for PartCategory tree.""" 

186 

187 class Meta: 

188 """Metaclass defining serializer fields.""" 

189 

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 ] 

203 

204 subcategories = serializers.IntegerField(label=_('Subcategories'), read_only=True) 

205 

206 icon = serializers.CharField( 

207 required=False, allow_blank=True, help_text=_('Icon (optional)'), max_length=100 

208 ) 

209 

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()) 

214 

215 

216@register_importer() 

217class PartTestTemplateSerializer( 

218 DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeModelSerializer 

219): 

220 """Serializer for the PartTestTemplate class.""" 

221 

222 class Meta: 

223 """Metaclass defining serializer fields.""" 

224 

225 model = PartTestTemplate 

226 

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 ] 

240 

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 ) 

247 

248 @staticmethod 

249 def annotate_queryset(queryset): 

250 """Custom query annotations for the PartTestTemplate serializer.""" 

251 return queryset.annotate(results=SubqueryCount('test_results')) 

252 

253 

254@register_importer() 

255class PartSalePriceSerializer( 

256 InvenTree.serializers.FilterableSerializerMixin, 

257 DataImportExportSerializerMixin, 

258 InvenTree.serializers.InvenTreeModelSerializer, 

259): 

260 """Serializer for sale prices for Part model.""" 

261 

262 no_filters = True 

263 

264 class Meta: 

265 """Metaclass defining serializer fields.""" 

266 

267 model = PartSellPriceBreak 

268 fields = ['pk', 'part', 'quantity', 'price', 'price_currency'] 

269 

270 quantity = InvenTree.serializers.InvenTreeDecimalField() 

271 

272 price = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) 

273 

274 price_currency = InvenTree.serializers.InvenTreeCurrencySerializer( 

275 help_text=_('Purchase currency of this stock item') 

276 ) 

277 

278 

279class PartInternalPriceSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

280 """Serializer for internal prices for Part model.""" 

281 

282 class Meta: 

283 """Metaclass defining serializer fields.""" 

284 

285 model = PartInternalPriceBreak 

286 fields = ['pk', 'part', 'quantity', 'price', 'price_currency'] 

287 

288 quantity = InvenTree.serializers.InvenTreeDecimalField() 

289 

290 price = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) 

291 

292 price_currency = InvenTree.serializers.InvenTreeCurrencySerializer( 

293 help_text=_('Purchase currency of this stock item') 

294 ) 

295 

296 

297class PartThumbSerializer(serializers.Serializer): 

298 """Serializer for the 'image' field of the Part model. 

299 

300 Used to serve and display existing Part images. 

301 """ 

302 

303 image = InvenTree.serializers.InvenTreeImageSerializerField(read_only=True) 

304 count = serializers.IntegerField(read_only=True) 

305 

306 

307class PartThumbSerializerUpdate(InvenTree.serializers.InvenTreeModelSerializer): 

308 """Serializer for updating Part thumbnail.""" 

309 

310 class Meta: 

311 """Metaclass defining serializer fields.""" 

312 

313 model = Part 

314 fields = ['image'] 

315 

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 

322 

323 image = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True) 

324 

325 

326class PartBriefSerializer( 

327 InvenTree.serializers.FilterableSerializerMixin, 

328 InvenTree.serializers.InvenTreeModelSerializer, 

329): 

330 """Serializer for Part (brief detail).""" 

331 

332 class Meta: 

333 """Metaclass defining serializer fields.""" 

334 

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'] 

365 

366 category_default_location = serializers.IntegerField( 

367 read_only=True, allow_null=True 

368 ) 

369 

370 image = InvenTree.serializers.InvenTreeImageSerializerField( 

371 read_only=True, allow_null=True 

372 ) 

373 thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) 

374 

375 IPN = serializers.CharField( 

376 required=False, 

377 allow_null=True, 

378 help_text=_('Internal Part Number'), 

379 max_length=100, 

380 ) 

381 

382 revision = serializers.CharField( 

383 required=False, default='', allow_blank=True, allow_null=True, max_length=100 

384 ) 

385 

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 ) 

397 

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 ) 

408 

409 

410class DuplicatePartSerializer(serializers.Serializer): 

411 """Serializer for specifying options when duplicating a Part. 

412 

413 The fields in this serializer control how the Part is duplicated. 

414 """ 

415 

416 class Meta: 

417 """Metaclass options.""" 

418 

419 fields = [ 

420 'part', 

421 'copy_image', 

422 'copy_bom', 

423 'copy_parameters', 

424 'copy_notes', 

425 'copy_tests', 

426 ] 

427 

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 ) 

434 

435 copy_image = serializers.BooleanField( 

436 label=_('Copy Image'), 

437 help_text=_('Copy image from original part'), 

438 required=False, 

439 default=False, 

440 ) 

441 

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 ) 

448 

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 ) 

455 

456 copy_notes = serializers.BooleanField( 

457 label=_('Copy Notes'), 

458 help_text=_('Copy notes from original part'), 

459 required=False, 

460 default=True, 

461 ) 

462 

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 ) 

469 

470 

471class InitialStockSerializer(serializers.Serializer): 

472 """Serializer for creating initial stock quantity.""" 

473 

474 class Meta: 

475 """Metaclass options.""" 

476 

477 fields = ['quantity', 'location'] 

478 

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 ) 

489 

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 ) 

497 

498 

499class InitialSupplierSerializer(serializers.Serializer): 

500 """Serializer for adding initial supplier / manufacturer information.""" 

501 

502 class Meta: 

503 """Metaclass options.""" 

504 

505 fields = ['supplier', 'sku', 'manufacturer', 'mpn'] 

506 

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 ) 

514 

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 ) 

522 

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 ) 

530 

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 ) 

538 

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 ) 

545 

546 return company 

547 

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 ) 

554 

555 return company 

556 

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 }) 

565 

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 }) 

572 

573 return data 

574 

575 

576class DefaultLocationSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

577 """Brief serializer for a StockLocation object. 

578 

579 Defined here, rather than stock.serializers, to negotiate circular imports. 

580 """ 

581 

582 class Meta: 

583 """Metaclass options.""" 

584 

585 import stock.models as stock_models 

586 

587 model = stock_models.StockLocation 

588 fields = ['pk', 'name', 'pathstring'] 

589 

590 

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. 

600 

601 Used when displaying all details of a single component. 

602 """ 

603 

604 import_exclude_fields = ['creation_date', 'creation_user', 'duplicate'] 

605 

606 class Meta: 

607 """Metaclass defining serializer fields.""" 

608 

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'] 

680 

681 def __init__(self, *args, **kwargs): 

682 """Custom initialization method for PartSerializer. 

683 

684 - Allows us to optionally pass extra fields based on the query. 

685 """ 

686 create = kwargs.pop('create', False) 

687 

688 super().__init__(*args, **kwargs) 

689 

690 if isGeneratingSchema(): 690 ↛ 691line 690 didn't jump to line 691 because the condition on line 690 was never true

691 return 

692 

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) 

700 

701 def get_api_url(self): 

702 """Return the API url associated with this serializer.""" 

703 return reverse_lazy('api-part-list') 

704 

705 def skip_create_fields(self): 

706 """Skip these fields when instantiating a new Part instance.""" 

707 fields = super().skip_create_fields() 

708 

709 fields += [ 

710 'duplicate', 

711 'initial_stock', 

712 'initial_supplier', 

713 'copy_category_parameters', 

714 'existing_image', 

715 ] 

716 

717 return fields 

718 

719 @staticmethod 

720 def annotate_queryset(queryset): 

721 """Add some extra annotations to the queryset. 

722 

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')) 

727 

728 # Annotate with the total number of stock items 

729 queryset = queryset.annotate(stock_item_count=SubqueryCount('stock_items')) 

730 

731 # Annotate with the total variant stock quantity 

732 variant_query = part_filters.variant_stock_query() 

733 

734 queryset = queryset.annotate( 

735 variant_stock=part_filters.annotate_variant_quantity( 

736 variant_query, reference='quantity' 

737 ) 

738 ) 

739 

740 # Annotate with the total 'building' quantity 

741 queryset = queryset.annotate( 

742 building=part_filters.annotate_in_production_quantity() 

743 ) 

744 

745 queryset = queryset.annotate( 

746 scheduled_to_build=part_filters.annotate_scheduled_to_build_quantity() 

747 ) 

748 

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 ) 

757 

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 ) 

765 

766 queryset = queryset.annotate( 

767 external_stock=part_filters.annotate_total_stock( 

768 filter=Q(location__external=True) 

769 ) 

770 ) 

771 

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 ) 

788 

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 ) 

796 

797 queryset = queryset.annotate( 

798 category_default_location=part_filters.annotate_default_location( 

799 'category__' 

800 ) 

801 ) 

802 

803 return queryset 

804 

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 

809 

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 ] 

815 

816 return part.pk in self.starred_parts 

817 

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 ) 

829 

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 ) 

841 

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 ) 

853 

854 category_name = serializers.CharField( 

855 source='category.name', read_only=True, label=_('Category Name') 

856 ) 

857 

858 responsible = serializers.PrimaryKeyRelatedField( 

859 queryset=users.models.Owner.objects.all(), 

860 required=False, 

861 allow_null=True, 

862 source='responsible_owner', 

863 ) 

864 

865 creation_user = serializers.PrimaryKeyRelatedField( 

866 queryset=users.models.User.objects.all(), required=False, allow_null=True 

867 ) 

868 

869 IPN = serializers.CharField( 

870 required=False, default='', allow_blank=True, label=_('IPN'), max_length=100 

871 ) 

872 

873 revision = serializers.CharField( 

874 required=False, default='', allow_blank=True, allow_null=True, max_length=100 

875 ) 

876 

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) 

880 

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 ) 

887 

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 ) 

894 

895 in_stock = serializers.FloatField( 

896 read_only=True, allow_null=True, label=_('In Stock') 

897 ) 

898 

899 ordering = serializers.FloatField( 

900 read_only=True, allow_null=True, label=_('On Order') 

901 ) 

902 

903 required_for_build_orders = serializers.IntegerField( 

904 read_only=True, allow_null=True 

905 ) 

906 

907 required_for_sales_orders = serializers.IntegerField( 

908 read_only=True, allow_null=True 

909 ) 

910 

911 stock_item_count = serializers.IntegerField( 

912 read_only=True, allow_null=True, label=_('Stock Items') 

913 ) 

914 

915 revision_count = serializers.IntegerField( 

916 read_only=True, allow_null=True, label=_('Revisions') 

917 ) 

918 

919 total_in_stock = serializers.FloatField( 

920 read_only=True, allow_null=True, label=_('Total Stock') 

921 ) 

922 

923 external_stock = serializers.FloatField( 

924 read_only=True, allow_null=True, label=_('External Stock') 

925 ) 

926 

927 unallocated_stock = serializers.FloatField( 

928 read_only=True, allow_null=True, label=_('Unallocated Stock') 

929 ) 

930 

931 category_default_location = serializers.IntegerField( 

932 read_only=True, allow_null=True 

933 ) 

934 

935 variant_stock = serializers.FloatField( 

936 read_only=True, allow_null=True, label=_('Variant Stock') 

937 ) 

938 

939 minimum_stock = serializers.FloatField( 

940 required=False, label=_('Minimum Stock'), default=0 

941 ) 

942 

943 maximum_stock = serializers.FloatField( 

944 required=False, label=_('Maximum Stock'), default=0 

945 ) 

946 

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() 

952 

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 ) 

957 

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 ) 

969 

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 ) 

980 

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 ) 

991 

992 parameters = common.filters.enable_parameters_filter() 

993 

994 tags = common.filters.enable_tags_filter() 

995 

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 ) 

1008 

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 ) 

1016 

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 ) 

1023 

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 ) 

1030 

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 ) 

1038 

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 ) 

1047 

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 

1052 

1053 img = img.split(os.path.sep)[-1] 

1054 

1055 # Ensure that the file actually exists 

1056 img_path = os.path.join(part_helpers.get_part_image_directory(), img) 

1057 

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')) 

1060 

1061 return img 

1062 

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) 

1070 

1071 instance = super().create(validated_data) 

1072 

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() 

1077 

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'] 

1081 

1082 if duplicate.get('copy_bom', False): 

1083 instance.copy_bom_from(original) 

1084 

1085 if duplicate.get('copy_notes', False): 

1086 instance.notes = original.notes 

1087 instance.save() 

1088 

1089 if duplicate.get('copy_image', False): 

1090 instance.image = original.image 

1091 instance.save() 

1092 

1093 if duplicate.get('copy_parameters', False): 

1094 instance.copy_parameters_from(original) 

1095 

1096 if duplicate.get('copy_tests', False): 

1097 instance.copy_tests_from(original) 

1098 

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) 

1103 

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 

1108 

1109 if quantity > 0: 

1110 stockitem = stock.models.StockItem( 

1111 part=instance, quantity=quantity, location=location 

1112 ) 

1113 

1114 if request := self.context.get('request', None): 

1115 stockitem.save(user=request.user) 

1116 

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', '') 

1121 

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 

1128 

1129 supplier = initial_supplier.get('supplier', None) 

1130 sku = initial_supplier.get('sku', '') 

1131 

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 ) 

1139 

1140 return instance 

1141 

1142 def save(self): 

1143 """Save the Part instance.""" 

1144 super().save() 

1145 

1146 part = self.instance 

1147 data = self.validated_data 

1148 

1149 # TODO: Remove the existing_image field entirely! 

1150 existing_image = data.pop('existing_image', None) 

1151 

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) 

1154 

1155 part.image = img_path 

1156 part.save() 

1157 

1158 return self.instance 

1159 

1160 

1161class PartBomValidateSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

1162 """Serializer for Part BOM information.""" 

1163 

1164 class Meta: 

1165 """Metaclass options.""" 

1166 

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 ] 

1177 

1178 read_only_fields = [ 

1179 'bom_validated', 

1180 'bom_checksum', 

1181 'bom_checked_by', 

1182 'bom_checked_by_detail', 

1183 'bom_checked_date', 

1184 ] 

1185 

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 ) 

1193 

1194 bom_checked_by_detail = UserSerializer( 

1195 source='bom_checked_by', many=False, read_only=True, allow_null=True 

1196 ) 

1197 

1198 

1199class PartRequirementsSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

1200 """Serializer for Part requirements.""" 

1201 

1202 class Meta: 

1203 """Metaclass options.""" 

1204 

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 ] 

1218 

1219 total_stock = serializers.FloatField(read_only=True, label=_('Total Stock')) 

1220 

1221 unallocated_stock = serializers.FloatField( 

1222 source='available_stock', read_only=True, label=_('Available Stock') 

1223 ) 

1224 

1225 can_build = serializers.FloatField(read_only=True, label=_('Can Build')) 

1226 

1227 ordering = serializers.FloatField( 

1228 source='on_order', read_only=True, label=_('On Order') 

1229 ) 

1230 

1231 building = serializers.FloatField( 

1232 read_only=True, label=_('In Production'), source='quantity_in_production' 

1233 ) 

1234 

1235 scheduled_to_build = serializers.IntegerField( 

1236 read_only=True, label=_('Scheduled to Build'), source='quantity_being_built' 

1237 ) 

1238 

1239 required_for_build_orders = serializers.FloatField( 

1240 source='required_build_order_quantity', 

1241 read_only=True, 

1242 label=_('Required for Build Orders'), 

1243 ) 

1244 

1245 allocated_to_build_orders = serializers.FloatField( 

1246 read_only=True, 

1247 label=_('Allocated to Build Orders'), 

1248 source='build_order_allocation_count', 

1249 ) 

1250 

1251 required_for_sales_orders = serializers.FloatField( 

1252 source='required_sales_order_quantity', 

1253 read_only=True, 

1254 label=_('Required for Sales Orders'), 

1255 ) 

1256 

1257 allocated_to_sales_orders = serializers.SerializerMethodField( 

1258 read_only=True, label=_('Allocated to Sales Orders') 

1259 ) 

1260 

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) 

1264 

1265 

1266class PartStocktakeSerializer( 

1267 InvenTree.serializers.FilterableSerializerMixin, 

1268 DataExportSerializerMixin, 

1269 InvenTree.serializers.InvenTreeModelSerializer, 

1270): 

1271 """Serializer for the PartStocktake model.""" 

1272 

1273 class Meta: 

1274 """Metaclass options.""" 

1275 

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 ] 

1293 

1294 read_only_fields = ['date', 'user'] 

1295 

1296 def __init__(self, *args, **kwargs): 

1297 """Custom initialization for PartStocktakeSerializer.""" 

1298 exclude_pk = kwargs.pop('exclude_pk', False) 

1299 

1300 super().__init__(*args, **kwargs) 

1301 

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) 

1304 

1305 quantity = serializers.FloatField() 

1306 

1307 cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) 

1308 cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer() 

1309 

1310 cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) 

1311 cost_max_currency = InvenTree.serializers.InvenTreeCurrencySerializer() 

1312 

1313 part_name = serializers.CharField( 

1314 source='part.name', read_only=True, label=_('Part Name') 

1315 ) 

1316 

1317 part_ipn = serializers.CharField( 

1318 source='part.IPN', read_only=True, allow_null=True, label=_('Part IPN') 

1319 ) 

1320 

1321 part_description = serializers.CharField( 

1322 source='part.description', 

1323 read_only=True, 

1324 allow_null=True, 

1325 label=_('Part Description'), 

1326 ) 

1327 

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 ) 

1339 

1340 def save(self): 

1341 """Called when this serializer is saved.""" 

1342 data = self.validated_data 

1343 

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() 

1348 

1349 

1350class PartStocktakeGenerateSerializer(serializers.Serializer): 

1351 """Serializer for generating PartStocktake entries.""" 

1352 

1353 class Meta: 

1354 """Metaclass options.""" 

1355 

1356 fields = [ 

1357 'part', 

1358 'category', 

1359 'location', 

1360 'generate_entry', 

1361 'generate_report', 

1362 'output', 

1363 ] 

1364 

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 ) 

1374 

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 ) 

1384 

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 ) 

1394 

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 ) 

1402 

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 ) 

1410 

1411 output = common.serializers.DataOutputSerializer( 

1412 read_only=True, many=False, label=_('Output') 

1413 ) 

1414 

1415 

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.""" 

1429 

1430 

1431class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

1432 """Serializer for Part pricing information.""" 

1433 

1434 class Meta: 

1435 """Metaclass defining serializer fields.""" 

1436 

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 ] 

1464 

1465 currency = serializers.CharField(allow_null=True, read_only=True) 

1466 

1467 updated = serializers.DateTimeField(allow_null=True, read_only=True) 

1468 

1469 scheduled_for_update = serializers.BooleanField(read_only=True) 

1470 

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 ) 

1478 

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 ) 

1485 

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 ) 

1492 

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 ) 

1499 

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 ) 

1506 

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 ) 

1514 

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 ) 

1521 

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 ) 

1529 

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 ) 

1536 

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 ) 

1543 

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 ) 

1550 

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 ) 

1557 

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 ) 

1566 

1567 def validate(self, data): 

1568 """Validate supplied pricing data.""" 

1569 super().validate(data) 

1570 

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) 

1574 

1575 default_currency = common.currency.currency_code_default() 

1576 

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 ) 

1587 

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 }) 

1597 

1598 return data 

1599 

1600 def save(self): 

1601 """Called when the serializer is saved.""" 

1602 super().save() 

1603 

1604 data = self.validated_data 

1605 

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() 

1610 

1611 

1612class PartSerialNumberSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

1613 """Serializer for Part serial number information.""" 

1614 

1615 class Meta: 

1616 """Metaclass defining serializer fields.""" 

1617 

1618 model = Part 

1619 fields = ['latest', 'next'] 

1620 

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) 

1625 

1626 

1627class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

1628 """Serializer for a PartRelated model.""" 

1629 

1630 class Meta: 

1631 """Metaclass defining serializer fields.""" 

1632 

1633 model = PartRelated 

1634 fields = ['pk', 'part_1', 'part_1_detail', 'part_2', 'part_2_detail', 'note'] 

1635 

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) 

1638 

1639 

1640class PartStarSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

1641 """Serializer for a PartStar object.""" 

1642 

1643 class Meta: 

1644 """Metaclass defining serializer fields.""" 

1645 

1646 model = PartStar 

1647 fields = ['pk', 'part', 'partname', 'user', 'username'] 

1648 

1649 partname = serializers.CharField(source='part.full_name', read_only=True) 

1650 username = serializers.CharField(source='user.username', read_only=True) 

1651 

1652 

1653class BomItemSubstituteSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

1654 """Serializer for the BomItemSubstitute class.""" 

1655 

1656 class Meta: 

1657 """Metaclass defining serializer fields.""" 

1658 

1659 model = BomItemSubstitute 

1660 fields = ['pk', 'bom_item', 'part', 'part_detail'] 

1661 # list_serializer_class = FilterableListSerializer 

1662 

1663 part_detail = PartBriefSerializer( 

1664 source='part', read_only=True, many=False, pricing=False 

1665 ) 

1666 

1667 

1668@register_importer() 

1669class BomItemSerializer( 

1670 InvenTree.serializers.FilterableSerializerMixin, 

1671 DataImportExportSerializerMixin, 

1672 InvenTree.serializers.InvenTreeModelSerializer, 

1673): 

1674 """Serializer for BomItem object.""" 

1675 

1676 import_exclude_fields = ['quantity', 'validated', 'substitutes'] 

1677 

1678 export_exclude_fields = ['substitutes'] 

1679 

1680 export_child_fields = [ 

1681 'sub_part_detail.name', 

1682 'sub_part_detail.IPN', 

1683 'sub_part_detail.description', 

1684 ] 

1685 

1686 class Meta: 

1687 """Metaclass defining serializer fields.""" 

1688 

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 ] 

1728 

1729 raw_amount = serializers.CharField( 

1730 label=_('Amount'), 

1731 help_text=_('Amount required for this item (can include units)'), 

1732 required=False, 

1733 ) 

1734 

1735 quantity = InvenTree.serializers.InvenTreeDecimalField(required=False) 

1736 

1737 setup_quantity = InvenTree.serializers.InvenTreeDecimalField(required=False) 

1738 

1739 attrition = InvenTree.serializers.InvenTreeDecimalField(required=False) 

1740 

1741 rounding_multiple = InvenTree.serializers.InvenTreeDecimalField( 

1742 required=False, allow_null=True 

1743 ) 

1744 

1745 part = serializers.PrimaryKeyRelatedField( 

1746 queryset=Part.objects.filter(assembly=True), 

1747 label=_('Assembly'), 

1748 help_text=_('Select the parent assembly'), 

1749 ) 

1750 

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 ) 

1768 

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 ) 

1779 

1780 sub_part = serializers.PrimaryKeyRelatedField( 

1781 queryset=Part.objects.filter(component=True), 

1782 label=_('Component'), 

1783 help_text=_('Select the component part'), 

1784 ) 

1785 

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 ) 

1797 

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 ) 

1809 

1810 on_order = serializers.FloatField( 

1811 label=_('On Order'), read_only=True, allow_null=True 

1812 ) 

1813 

1814 building = serializers.FloatField( 

1815 label=_('In Production'), read_only=True, allow_null=True 

1816 ) 

1817 

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 ) 

1827 

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 ) 

1839 

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 ) 

1850 

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 ) 

1857 

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 ) 

1864 

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 ) 

1875 

1876 # Annotated fields for available stock 

1877 available_stock = serializers.FloatField( 

1878 label=_('Available Stock'), read_only=True, allow_null=True 

1879 ) 

1880 

1881 available_substitute_stock = serializers.FloatField(read_only=True, allow_null=True) 

1882 available_variant_stock = serializers.FloatField(read_only=True, allow_null=True) 

1883 

1884 external_stock = serializers.FloatField(read_only=True, allow_null=True) 

1885 

1886 def validate(self, data): 

1887 """Validate the supplied data. 

1888 

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) 

1893 

1894 if 'raw_amount' not in data and qty is not None: 

1895 data['raw_amount'] = qty 

1896 

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')}) 

1904 

1905 return super().validate(data) 

1906 

1907 @staticmethod 

1908 def annotate_queryset(queryset): 

1909 """Annotate the BomItem queryset with extra information. 

1910 

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 """ 

1918 

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 ) 

1933 

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 ) 

1945 

1946 ref = 'sub_part__' 

1947 

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 ) 

1952 

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 ) 

1957 

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 ) 

1964 

1965 # Annotate available stock and "can_build" quantities 

1966 queryset = part_filters.annotate_bom_item_can_build(queryset) 

1967 

1968 return queryset 

1969 

1970 

1971@register_importer() 

1972class CategoryParameterTemplateSerializer( 

1973 InvenTree.serializers.FilterableSerializerMixin, 

1974 DataImportExportSerializerMixin, 

1975 InvenTree.serializers.InvenTreeModelSerializer, 

1976): 

1977 """Serializer for the PartCategoryParameterTemplate model.""" 

1978 

1979 class Meta: 

1980 """Metaclass defining serializer fields.""" 

1981 

1982 model = PartCategoryParameterTemplate 

1983 fields = [ 

1984 'pk', 

1985 'category', 

1986 'category_detail', 

1987 'template', 

1988 'template_detail', 

1989 'default_value', 

1990 ] 

1991 

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 ) 

1998 

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 ) 

2010 

2011 

2012class PartCopyBOMSerializer(serializers.Serializer): 

2013 """Serializer for copying a BOM from another part.""" 

2014 

2015 class Meta: 

2016 """Metaclass defining serializer fields.""" 

2017 

2018 fields = [ 

2019 'part', 

2020 'remove_existing', 

2021 'copy_substitutes', 

2022 'include_inherited', 

2023 'skip_invalid', 

2024 ] 

2025 

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 ) 

2034 

2035 def validate_part(self, part): 

2036 """Check that a 'valid' part was selected.""" 

2037 return part 

2038 

2039 remove_existing = serializers.BooleanField( 

2040 label=_('Remove Existing Data'), 

2041 help_text=_('Remove existing BOM items before copying'), 

2042 default=True, 

2043 ) 

2044 

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 ) 

2050 

2051 skip_invalid = serializers.BooleanField( 

2052 label=_('Skip Invalid Rows'), 

2053 help_text=_('Enable this option to skip invalid rows'), 

2054 default=False, 

2055 ) 

2056 

2057 copy_substitutes = serializers.BooleanField( 

2058 label=_('Copy Substitute Parts'), 

2059 help_text=_('Copy substitute parts when duplicate BOM items'), 

2060 default=True, 

2061 ) 

2062 

2063 def save(self): 

2064 """Actually duplicate the BOM.""" 

2065 base_part = self.context['part'] 

2066 

2067 data = self.validated_data 

2068 

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 )