• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

inventree / InvenTree / 4548642839

pending completion
4548642839

push

github

GitHub
[Feature] Add RMA support (#4488)

1082 of 1082 new or added lines in 49 files covered. (100.0%)

26469 of 30045 relevant lines covered (88.1%)

0.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.4
/InvenTree/part/api.py
1
"""Provides a JSON API for the Part app."""
2

3
import functools
1✔
4

5
from django.db.models import Count, F, Q
1✔
6
from django.http import JsonResponse
1✔
7
from django.urls import include, path, re_path
1✔
8
from django.utils.translation import gettext_lazy as _
1✔
9

10
from django_filters import rest_framework as rest_filters
1✔
11
from django_filters.rest_framework import DjangoFilterBackend
1✔
12
from rest_framework import filters, permissions, serializers, status
1✔
13
from rest_framework.exceptions import ValidationError
1✔
14
from rest_framework.response import Response
1✔
15

16
import order.models
1✔
17
from build.models import Build, BuildItem
1✔
18
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
1✔
19
                           ListCreateDestroyAPIView)
20
from InvenTree.filters import InvenTreeOrderingFilter
1✔
21
from InvenTree.helpers import (DownloadFile, increment_serial_number, isNull,
1✔
22
                               str2bool, str2int)
23
from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
1✔
24
                              ListAPI, ListCreateAPI, RetrieveAPI,
25
                              RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
26
                              UpdateAPI)
27
from InvenTree.permissions import RolePermission
1✔
28
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
1✔
29
                                    SalesOrderStatus)
30
from part.admin import PartCategoryResource, PartResource
1✔
31
from plugin.serializers import MetadataSerializer
1✔
32

33
from . import serializers as part_serializers
1✔
34
from . import views
1✔
35
from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
1✔
36
                     PartCategory, PartCategoryParameterTemplate,
37
                     PartInternalPriceBreak, PartParameter,
38
                     PartParameterTemplate, PartRelated, PartSellPriceBreak,
39
                     PartStocktake, PartStocktakeReport, PartTestTemplate)
40

41

42
class CategoryMixin:
1✔
43
    """Mixin class for PartCategory endpoints"""
44
    serializer_class = part_serializers.CategorySerializer
1✔
45
    queryset = PartCategory.objects.all()
1✔
46

47
    def get_queryset(self, *args, **kwargs):
1✔
48
        """Return an annotated queryset for the CategoryDetail endpoint"""
49

50
        queryset = super().get_queryset(*args, **kwargs)
1✔
51
        queryset = part_serializers.CategorySerializer.annotate_queryset(queryset)
1✔
52
        return queryset
1✔
53

54
    def get_serializer_context(self):
1✔
55
        """Add extra context to the serializer for the CategoryDetail endpoint"""
56
        ctx = super().get_serializer_context()
1✔
57

58
        try:
1✔
59
            ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
1✔
60
        except AttributeError:
1✔
61
            # Error is thrown if the view does not have an associated request
62
            ctx['starred_categories'] = []
1✔
63

64
        return ctx
1✔
65

66

67
class CategoryList(CategoryMixin, APIDownloadMixin, ListCreateAPI):
1✔
68
    """API endpoint for accessing a list of PartCategory objects.
69

70
    - GET: Return a list of PartCategory objects
71
    - POST: Create a new PartCategory object
72
    """
73

74
    def download_queryset(self, queryset, export_format):
1✔
75
        """Download the filtered queryset as a data file"""
76

77
        dataset = PartCategoryResource().export(queryset=queryset)
×
78
        filedata = dataset.export(export_format)
×
79
        filename = f"InvenTree_Categories.{export_format}"
×
80

81
        return DownloadFile(filedata, filename)
×
82

83
    def filter_queryset(self, queryset):
1✔
84
        """Custom filtering:
85

86
        - Allow filtering by "null" parent to retrieve top-level part categories
87
        """
88
        queryset = super().filter_queryset(queryset)
1✔
89

90
        params = self.request.query_params
1✔
91

92
        cat_id = params.get('parent', None)
1✔
93

94
        cascade = str2bool(params.get('cascade', False))
1✔
95

96
        depth = str2int(params.get('depth', None))
1✔
97

98
        # Do not filter by category
99
        if cat_id is None:
1✔
100
            pass
1✔
101
        # Look for top-level categories
102
        elif isNull(cat_id):
1✔
103

104
            if not cascade:
1✔
105
                queryset = queryset.filter(parent=None)
1✔
106

107
            if cascade and depth is not None:
1✔
108
                queryset = queryset.filter(level__lte=depth)
1✔
109

110
        else:
111
            try:
1✔
112
                category = PartCategory.objects.get(pk=cat_id)
1✔
113

114
                if cascade:
1✔
115
                    parents = category.get_descendants(include_self=True)
1✔
116
                    if depth is not None:
1✔
117
                        parents = parents.filter(level__lte=category.level + depth)
1✔
118

119
                    parent_ids = [p.id for p in parents]
1✔
120

121
                    queryset = queryset.filter(parent__in=parent_ids)
1✔
122
                else:
123
                    queryset = queryset.filter(parent=category)
1✔
124

125
            except (ValueError, PartCategory.DoesNotExist):
1✔
126
                pass
1✔
127

128
        # Exclude PartCategory tree
129
        exclude_tree = params.get('exclude_tree', None)
1✔
130

131
        if exclude_tree is not None:
1✔
132
            try:
1✔
133
                cat = PartCategory.objects.get(pk=exclude_tree)
1✔
134

135
                queryset = queryset.exclude(
1✔
136
                    pk__in=[c.pk for c in cat.get_descendants(include_self=True)]
137
                )
138

139
            except (ValueError, PartCategory.DoesNotExist):
1✔
140
                pass
1✔
141

142
        # Filter by "starred" status
143
        starred = params.get('starred', None)
1✔
144

145
        if starred is not None:
1✔
146
            starred = str2bool(starred)
1✔
147
            starred_categories = [star.category.pk for star in self.request.user.starred_categories.all()]
1✔
148

149
            if starred:
1✔
150
                queryset = queryset.filter(pk__in=starred_categories)
1✔
151
            else:
152
                queryset = queryset.exclude(pk__in=starred_categories)
1✔
153

154
        return queryset
1✔
155

156
    filter_backends = [
1✔
157
        DjangoFilterBackend,
158
        filters.SearchFilter,
159
        filters.OrderingFilter,
160
    ]
161

162
    filterset_fields = [
1✔
163
        'name',
164
        'description',
165
        'structural'
166
    ]
167

168
    ordering_fields = [
1✔
169
        'name',
170
        'pathstring',
171
        'level',
172
        'tree_id',
173
        'lft',
174
        'part_count',
175
    ]
176

177
    # Use hierarchical ordering by default
178
    ordering = [
1✔
179
        'tree_id',
180
        'lft',
181
        'name'
182
    ]
183

184
    search_fields = [
1✔
185
        'name',
186
        'description',
187
    ]
188

189

190
class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
1✔
191
    """API endpoint for detail view of a single PartCategory object."""
192

193
    def update(self, request, *args, **kwargs):
1✔
194
        """Perform 'update' function and mark this part as 'starred' (or not)"""
195
        # Clean up input data
196
        data = self.clean_data(request.data)
1✔
197

198
        if 'starred' in data:
1✔
199
            starred = str2bool(data.get('starred', False))
1✔
200

201
            self.get_object().set_starred(request.user, starred)
1✔
202

203
        response = super().update(request, *args, **kwargs)
1✔
204

205
        return response
1✔
206

207
    def destroy(self, request, *args, **kwargs):
1✔
208
        """Delete a Part category instance via the API"""
209
        delete_parts = 'delete_parts' in request.data and request.data['delete_parts'] == '1'
1✔
210
        delete_child_categories = 'delete_child_categories' in request.data and request.data['delete_child_categories'] == '1'
1✔
211
        return super().destroy(request,
1✔
212
                               *args,
213
                               **dict(kwargs,
214
                                      delete_parts=delete_parts,
215
                                      delete_child_categories=delete_child_categories))
216

217

218
class CategoryTree(ListAPI):
1✔
219
    """API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
220

221
    queryset = PartCategory.objects.all()
1✔
222
    serializer_class = part_serializers.CategoryTree
1✔
223

224
    filter_backends = [
1✔
225
        DjangoFilterBackend,
226
        filters.OrderingFilter,
227
    ]
228

229
    # Order by tree level (top levels first) and then name
230
    ordering = ['level', 'name']
1✔
231

232

233
class CategoryMetadata(RetrieveUpdateAPI):
1✔
234
    """API endpoint for viewing / updating PartCategory metadata."""
235

236
    def get_serializer(self, *args, **kwargs):
1✔
237
        """Return a MetadataSerializer pointing to the referenced PartCategory instance"""
238
        return MetadataSerializer(PartCategory, *args, **kwargs)
1✔
239

240
    queryset = PartCategory.objects.all()
1✔
241

242

243
class CategoryParameterList(ListCreateAPI):
1✔
244
    """API endpoint for accessing a list of PartCategoryParameterTemplate objects.
245

246
    - GET: Return a list of PartCategoryParameterTemplate objects
247
    """
248

249
    queryset = PartCategoryParameterTemplate.objects.all()
1✔
250
    serializer_class = part_serializers.CategoryParameterTemplateSerializer
1✔
251

252
    def get_queryset(self):
1✔
253
        """Custom filtering:
254

255
        - Allow filtering by "null" parent to retrieve all categories parameter templates
256
        - Allow filtering by category
257
        - Allow traversing all parent categories
258
        """
259
        queryset = super().get_queryset()
1✔
260

261
        params = self.request.query_params
1✔
262

263
        category = params.get('category', None)
1✔
264

265
        if category is not None:
1✔
266
            try:
1✔
267

268
                category = PartCategory.objects.get(pk=category)
1✔
269

270
                fetch_parent = str2bool(params.get('fetch_parent', True))
1✔
271

272
                if fetch_parent:
1✔
273
                    parents = category.get_ancestors(include_self=True)
1✔
274
                    queryset = queryset.filter(category__in=[cat.pk for cat in parents])
1✔
275
                else:
276
                    queryset = queryset.filter(category=category)
×
277

278
            except (ValueError, PartCategory.DoesNotExist):
×
279
                pass
×
280

281
        return queryset
1✔
282

283

284
class CategoryParameterDetail(RetrieveUpdateDestroyAPI):
1✔
285
    """Detail endpoint fro the PartCategoryParameterTemplate model"""
286

287
    queryset = PartCategoryParameterTemplate.objects.all()
1✔
288
    serializer_class = part_serializers.CategoryParameterTemplateSerializer
1✔
289

290

291
class PartSalePriceDetail(RetrieveUpdateDestroyAPI):
1✔
292
    """Detail endpoint for PartSellPriceBreak model."""
293

294
    queryset = PartSellPriceBreak.objects.all()
1✔
295
    serializer_class = part_serializers.PartSalePriceSerializer
1✔
296

297

298
class PartSalePriceList(ListCreateAPI):
1✔
299
    """API endpoint for list view of PartSalePriceBreak model."""
300

301
    queryset = PartSellPriceBreak.objects.all()
1✔
302
    serializer_class = part_serializers.PartSalePriceSerializer
1✔
303

304
    filter_backends = [
1✔
305
        DjangoFilterBackend
306
    ]
307

308
    filterset_fields = [
1✔
309
        'part',
310
    ]
311

312

313
class PartInternalPriceDetail(RetrieveUpdateDestroyAPI):
1✔
314
    """Detail endpoint for PartInternalPriceBreak model."""
315

316
    queryset = PartInternalPriceBreak.objects.all()
1✔
317
    serializer_class = part_serializers.PartInternalPriceSerializer
1✔
318

319

320
class PartInternalPriceList(ListCreateAPI):
1✔
321
    """API endpoint for list view of PartInternalPriceBreak model."""
322

323
    queryset = PartInternalPriceBreak.objects.all()
1✔
324
    serializer_class = part_serializers.PartInternalPriceSerializer
1✔
325
    permission_required = 'roles.sales_order.show'
1✔
326

327
    filter_backends = [
1✔
328
        DjangoFilterBackend
329
    ]
330

331
    filterset_fields = [
1✔
332
        'part',
333
    ]
334

335

336
class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
1✔
337
    """API endpoint for listing (and creating) a PartAttachment (file upload)."""
338

339
    queryset = PartAttachment.objects.all()
1✔
340
    serializer_class = part_serializers.PartAttachmentSerializer
1✔
341

342
    filter_backends = [
1✔
343
        DjangoFilterBackend,
344
    ]
345

346
    filterset_fields = [
1✔
347
        'part',
348
    ]
349

350

351
class PartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
1✔
352
    """Detail endpoint for PartAttachment model."""
353

354
    queryset = PartAttachment.objects.all()
1✔
355
    serializer_class = part_serializers.PartAttachmentSerializer
1✔
356

357

358
class PartTestTemplateDetail(RetrieveUpdateDestroyAPI):
1✔
359
    """Detail endpoint for PartTestTemplate model."""
360

361
    queryset = PartTestTemplate.objects.all()
1✔
362
    serializer_class = part_serializers.PartTestTemplateSerializer
1✔
363

364

365
class PartTestTemplateList(ListCreateAPI):
1✔
366
    """API endpoint for listing (and creating) a PartTestTemplate."""
367

368
    queryset = PartTestTemplate.objects.all()
1✔
369
    serializer_class = part_serializers.PartTestTemplateSerializer
1✔
370

371
    def filter_queryset(self, queryset):
1✔
372
        """Filter the test list queryset.
373

374
        If filtering by 'part', we include results for any parts "above" the specified part.
375
        """
376
        queryset = super().filter_queryset(queryset)
1✔
377

378
        params = self.request.query_params
1✔
379

380
        part = params.get('part', None)
1✔
381

382
        # Filter by part
383
        if part:
1✔
384
            try:
1✔
385
                part = Part.objects.get(pk=part)
1✔
386
                queryset = queryset.filter(part__in=part.get_ancestors(include_self=True))
1✔
387
            except (ValueError, Part.DoesNotExist):
×
388
                pass
×
389

390
        # Filter by 'required' status
391
        required = params.get('required', None)
1✔
392

393
        if required is not None:
1✔
394
            queryset = queryset.filter(required=str2bool(required))
×
395

396
        return queryset
1✔
397

398
    filter_backends = [
1✔
399
        DjangoFilterBackend,
400
        filters.OrderingFilter,
401
        filters.SearchFilter,
402
    ]
403

404

405
class PartThumbs(ListAPI):
1✔
406
    """API endpoint for retrieving information on available Part thumbnails."""
407

408
    queryset = Part.objects.all()
1✔
409
    serializer_class = part_serializers.PartThumbSerializer
1✔
410

411
    def get_queryset(self):
1✔
412
        """Return a queryset which exlcudes any parts without images"""
413
        queryset = super().get_queryset()
1✔
414

415
        # Get all Parts which have an associated image
416
        queryset = queryset.exclude(image='')
1✔
417

418
        return queryset
1✔
419

420
    def list(self, request, *args, **kwargs):
1✔
421
        """Serialize the available Part images.
422

423
        - Images may be used for multiple parts!
424
        """
425
        queryset = self.filter_queryset(self.get_queryset())
1✔
426

427
        # Return the most popular parts first
428
        data = queryset.values(
1✔
429
            'image',
430
        ).annotate(count=Count('image')).order_by('-count')
431

432
        return Response(data)
1✔
433

434
    filter_backends = [
1✔
435
        filters.SearchFilter,
436
    ]
437

438
    search_fields = [
1✔
439
        'name',
440
        'description',
441
        'IPN',
442
        'revision',
443
        'keywords',
444
        'category__name',
445
    ]
446

447

448
class PartThumbsUpdate(RetrieveUpdateAPI):
1✔
449
    """API endpoint for updating Part thumbnails."""
450

451
    queryset = Part.objects.all()
1✔
452
    serializer_class = part_serializers.PartThumbSerializerUpdate
1✔
453

454
    filter_backends = [
1✔
455
        DjangoFilterBackend
456
    ]
457

458

459
class PartScheduling(RetrieveAPI):
1✔
460
    """API endpoint for delivering "scheduling" information about a given part via the API.
461

462
    Returns a chronologically ordered list about future "scheduled" events,
463
    concerning stock levels for the part:
464

465
    - Purchase Orders (incoming stock)
466
    - Sales Orders (outgoing stock)
467
    - Build Orders (incoming completed stock)
468
    - Build Orders (outgoing allocated stock)
469
    """
470

471
    queryset = Part.objects.all()
1✔
472

473
    def retrieve(self, request, *args, **kwargs):
1✔
474
        """Return scheduling information for the referenced Part instance"""
475

476
        part = self.get_object()
×
477

478
        schedule = []
×
479

480
        def add_schedule_entry(date, quantity, title, label, url, speculative_quantity=0):
×
481
            """Check if a scheduled entry should be added:
482

483
            - date must be non-null
484
            - date cannot be in the "past"
485
            - quantity must not be zero
486
            """
487

488
            schedule.append({
×
489
                'date': date,
490
                'quantity': quantity,
491
                'speculative_quantity': speculative_quantity,
492
                'title': title,
493
                'label': label,
494
                'url': url,
495
            })
496

497
        # Add purchase order (incoming stock) information
498
        po_lines = order.models.PurchaseOrderLineItem.objects.filter(
×
499
            part__part=part,
500
            order__status__in=PurchaseOrderStatus.OPEN,
501
        )
502

503
        for line in po_lines:
×
504

505
            target_date = line.target_date or line.order.target_date
×
506

507
            quantity = max(line.quantity - line.received, 0)
×
508

509
            # Multiply by the pack_size of the SupplierPart
510
            quantity *= line.part.pack_size
×
511

512
            add_schedule_entry(
×
513
                target_date,
514
                quantity,
515
                _('Incoming Purchase Order'),
516
                str(line.order),
517
                line.order.get_absolute_url()
518
            )
519

520
        # Add sales order (outgoing stock) information
521
        so_lines = order.models.SalesOrderLineItem.objects.filter(
×
522
            part=part,
523
            order__status__in=SalesOrderStatus.OPEN,
524
        )
525

526
        for line in so_lines:
×
527

528
            target_date = line.target_date or line.order.target_date
×
529

530
            quantity = max(line.quantity - line.shipped, 0)
×
531

532
            add_schedule_entry(
×
533
                target_date,
534
                -quantity,
535
                _('Outgoing Sales Order'),
536
                str(line.order),
537
                line.order.get_absolute_url(),
538
            )
539

540
        # Add build orders (incoming stock) information
541
        build_orders = Build.objects.filter(
×
542
            part=part,
543
            status__in=BuildStatus.ACTIVE_CODES
544
        )
545

546
        for build in build_orders:
×
547

548
            quantity = max(build.quantity - build.completed, 0)
×
549

550
            add_schedule_entry(
×
551
                build.target_date,
552
                quantity,
553
                _('Stock produced by Build Order'),
554
                str(build),
555
                build.get_absolute_url(),
556
            )
557

558
        """
559
        Add build order allocation (outgoing stock) information.
560

561
        Here we need some careful consideration:
562

563
        - 'Tracked' stock items are removed from stock when the individual Build Output is completed
564
        - 'Untracked' stock items are removed from stock when the Build Order is completed
565

566
        The 'simplest' approach here is to look at existing BuildItem allocations which reference this part,
567
        and "schedule" them for removal at the time of build order completion.
568

569
        This assumes that the user is responsible for correctly allocating parts.
570

571
        However, it has the added benefit of side-stepping the various BOM substition options,
572
        and just looking at what stock items the user has actually allocated against the Build.
573
        """
574

575
        # Grab a list of BomItem objects that this part might be used in
576
        bom_items = BomItem.objects.filter(part.get_used_in_bom_item_filter())
×
577

578
        # Track all outstanding build orders
579
        seen_builds = set()
×
580

581
        for bom_item in bom_items:
×
582
            # Find a list of active builds for this BomItem
583

584
            if bom_item.inherited:
×
585
                # An "inherited" BOM item filters down to variant parts also
586
                childs = bom_item.part.get_descendants(include_self=True)
×
587
                builds = Build.objects.filter(
×
588
                    status__in=BuildStatus.ACTIVE_CODES,
589
                    part__in=childs,
590
                )
591
            else:
592
                builds = Build.objects.filter(
×
593
                    status__in=BuildStatus.ACTIVE_CODES,
594
                    part=bom_item.part,
595
                )
596

597
            for build in builds:
×
598

599
                # Ensure we don't double-count any builds
600
                if build in seen_builds:
×
601
                    continue
×
602

603
                seen_builds.add(build)
×
604

605
                if bom_item.sub_part.trackable:
×
606
                    # Trackable parts are allocated against the outputs
607
                    required_quantity = build.remaining * bom_item.quantity
×
608
                else:
609
                    # Non-trackable parts are allocated against the build itself
610
                    required_quantity = build.quantity * bom_item.quantity
×
611

612
                # Grab all allocations against the spefied BomItem
613
                allocations = BuildItem.objects.filter(
×
614
                    bom_item=bom_item,
615
                    build=build,
616
                )
617

618
                # Total allocated for *this* part
619
                part_allocated_quantity = 0
×
620

621
                # Total allocated for *any* part
622
                total_allocated_quantity = 0
×
623

624
                for allocation in allocations:
×
625
                    total_allocated_quantity += allocation.quantity
×
626

627
                    if allocation.stock_item.part == part:
×
628
                        part_allocated_quantity += allocation.quantity
×
629

630
                speculative_quantity = 0
×
631

632
                # Consider the case where the build order is *not* fully allocated
633
                if required_quantity > total_allocated_quantity:
×
634
                    speculative_quantity = -1 * (required_quantity - total_allocated_quantity)
×
635

636
                add_schedule_entry(
×
637
                    build.target_date,
638
                    -part_allocated_quantity,
639
                    _('Stock required for Build Order'),
640
                    str(build),
641
                    build.get_absolute_url(),
642
                    speculative_quantity=speculative_quantity
643
                )
644

645
        def compare(entry_1, entry_2):
×
646
            """Comparison function for sorting entries by date.
647

648
            Account for the fact that either date might be None
649
            """
650

651
            date_1 = entry_1['date']
×
652
            date_2 = entry_2['date']
×
653

654
            if date_1 is None:
×
655
                return -1
×
656
            elif date_2 is None:
×
657
                return 1
×
658

659
            return -1 if date_1 < date_2 else 1
×
660

661
        # Sort by incrementing date values
662
        schedule = sorted(schedule, key=functools.cmp_to_key(compare))
×
663

664
        return Response(schedule)
×
665

666

667
class PartRequirements(RetrieveAPI):
1✔
668
    """API endpoint detailing 'requirements' information for a particular part.
669

670
    This endpoint returns information on upcoming requirements for:
671

672
    - Sales Orders
673
    - Build Orders
674
    - Total requirements
675

676
    As this data is somewhat complex to calculate, is it not included in the default API
677
    """
678

679
    queryset = Part.objects.all()
1✔
680

681
    def retrieve(self, request, *args, **kwargs):
1✔
682
        """Construct a response detailing Part requirements"""
683

684
        part = self.get_object()
×
685

686
        data = {
×
687
            "available_stock": part.available_stock,
688
            "on_order": part.on_order,
689
            "required_build_order_quantity": part.required_build_order_quantity(),
690
            "allocated_build_order_quantity": part.build_order_allocation_count(),
691
            "required_sales_order_quantity": part.required_sales_order_quantity(),
692
            "allocated_sales_order_quantity": part.sales_order_allocation_count(pending=True),
693
        }
694

695
        data["allocated"] = data["allocated_build_order_quantity"] + data["allocated_sales_order_quantity"]
×
696
        data["required"] = data["required_build_order_quantity"] + data["required_sales_order_quantity"]
×
697

698
        return Response(data)
×
699

700

701
class PartMetadata(RetrieveUpdateAPI):
1✔
702
    """API endpoint for viewing / updating Part metadata."""
703

704
    def get_serializer(self, *args, **kwargs):
1✔
705
        """Returns a MetadataSerializer instance pointing to the referenced Part"""
706
        return MetadataSerializer(Part, *args, **kwargs)
1✔
707

708
    queryset = Part.objects.all()
1✔
709

710

711
class PartPricingDetail(RetrieveUpdateAPI):
1✔
712
    """API endpoint for viewing part pricing data"""
713

714
    serializer_class = part_serializers.PartPricingSerializer
1✔
715
    queryset = Part.objects.all()
1✔
716

717
    def get_object(self):
1✔
718
        """Return the PartPricing object associated with the linked Part"""
719

720
        part = super().get_object()
1✔
721
        return part.pricing
1✔
722

723
    def _get_serializer(self, *args, **kwargs):
1✔
724
        """Return a part pricing serializer object"""
725

726
        part = self.get_object()
×
727
        kwargs['instance'] = part.pricing
×
728

729
        return self.serializer_class(**kwargs)
×
730

731

732
class PartSerialNumberDetail(RetrieveAPI):
1✔
733
    """API endpoint for returning extra serial number information about a particular part."""
734

735
    queryset = Part.objects.all()
1✔
736

737
    def retrieve(self, request, *args, **kwargs):
1✔
738
        """Return serial number information for the referenced Part instance"""
739
        part = self.get_object()
×
740

741
        # Calculate the "latest" serial number
742
        latest = part.get_latest_serial_number()
×
743

744
        data = {
×
745
            'latest': latest,
746
        }
747

748
        if latest is not None:
×
749
            next_serial = increment_serial_number(latest)
×
750

751
            if next_serial != latest:
×
752
                data['next'] = next_serial
×
753

754
        return Response(data)
×
755

756

757
class PartCopyBOM(CreateAPI):
1✔
758
    """API endpoint for duplicating a BOM."""
759

760
    queryset = Part.objects.all()
1✔
761
    serializer_class = part_serializers.PartCopyBOMSerializer
1✔
762

763
    def get_serializer_context(self):
1✔
764
        """Add custom information to the serializer context for this endpoint"""
765
        ctx = super().get_serializer_context()
1✔
766

767
        try:
1✔
768
            ctx['part'] = Part.objects.get(pk=self.kwargs.get('pk', None))
1✔
769
        except Exception:
1✔
770
            pass
1✔
771

772
        return ctx
1✔
773

774

775
class PartValidateBOM(RetrieveUpdateAPI):
1✔
776
    """API endpoint for 'validating' the BOM for a given Part."""
777

778
    class BOMValidateSerializer(serializers.ModelSerializer):
1✔
779
        """Simple serializer class for validating a single BomItem instance"""
780

781
        class Meta:
1✔
782
            """Metaclass defines serializer fields"""
783
            model = Part
1✔
784
            fields = [
1✔
785
                'checksum',
786
                'valid',
787
            ]
788

789
        checksum = serializers.CharField(
1✔
790
            read_only=True,
791
            source='bom_checksum',
792
        )
793

794
        valid = serializers.BooleanField(
1✔
795
            write_only=True,
796
            default=False,
797
            label=_('Valid'),
798
            help_text=_('Validate entire Bill of Materials'),
799
        )
800

801
        def validate_valid(self, valid):
1✔
802
            """Check that the 'valid' input was flagged"""
803
            if not valid:
×
804
                raise ValidationError(_('This option must be selected'))
×
805

806
    queryset = Part.objects.all()
1✔
807

808
    serializer_class = BOMValidateSerializer
1✔
809

810
    def update(self, request, *args, **kwargs):
1✔
811
        """Validate the referenced BomItem instance"""
812
        part = self.get_object()
×
813

814
        partial = kwargs.pop('partial', False)
×
815

816
        # Clean up input data before using it
817
        data = self.clean_data(request.data)
×
818

819
        serializer = self.get_serializer(part, data=data, partial=partial)
×
820
        serializer.is_valid(raise_exception=True)
×
821

822
        part.validate_bom(request.user)
×
823

824
        return Response({
×
825
            'checksum': part.bom_checksum,
826
        })
827

828

829
class PartFilter(rest_filters.FilterSet):
1✔
830
    """Custom filters for the PartList endpoint.
831

832
    Uses the django_filters extension framework
833
    """
834

835
    # Filter by parts which have (or not) an IPN value
836
    has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn')
1✔
837

838
    def filter_has_ipn(self, queryset, name, value):
1✔
839
        """Filter by whether the Part has an IPN (internal part number) or not"""
840
        value = str2bool(value)
×
841

842
        if value:
×
843
            queryset = queryset.exclude(IPN='')
×
844
        else:
845
            queryset = queryset.filter(IPN='')
×
846

847
        return queryset
×
848

849
    # Regex filter for name
850
    name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex')
1✔
851

852
    # Exact match for IPN
853
    IPN = rest_filters.CharFilter(
1✔
854
        label='Filter by exact IPN (internal part number)',
855
        field_name='IPN',
856
        lookup_expr="iexact"
857
    )
858

859
    # Regex match for IPN
860
    IPN_regex = rest_filters.CharFilter(label='Filter by regex on IPN (internal part number)', field_name='IPN', lookup_expr='iregex')
1✔
861

862
    # low_stock filter
863
    low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock')
1✔
864

865
    def filter_low_stock(self, queryset, name, value):
1✔
866
        """Filter by "low stock" status."""
867
        value = str2bool(value)
×
868

869
        if value:
×
870
            # Ignore any parts which do not have a specified 'minimum_stock' level
871
            queryset = queryset.exclude(minimum_stock=0)
×
872
            # Filter items which have an 'in_stock' level lower than 'minimum_stock'
873
            queryset = queryset.filter(Q(in_stock__lt=F('minimum_stock')))
×
874
        else:
875
            # Filter items which have an 'in_stock' level higher than 'minimum_stock'
876
            queryset = queryset.filter(Q(in_stock__gte=F('minimum_stock')))
×
877

878
        return queryset
×
879

880
    # has_stock filter
881
    has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock')
1✔
882

883
    def filter_has_stock(self, queryset, name, value):
1✔
884
        """Filter by whether the Part has any stock"""
885
        value = str2bool(value)
×
886

887
        if value:
×
888
            queryset = queryset.filter(Q(in_stock__gt=0))
×
889
        else:
890
            queryset = queryset.filter(Q(in_stock__lte=0))
×
891

892
        return queryset
×
893

894
    # unallocated_stock filter
895
    unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock')
1✔
896

897
    def filter_unallocated_stock(self, queryset, name, value):
1✔
898
        """Filter by whether the Part has unallocated stock"""
899
        value = str2bool(value)
×
900

901
        if value:
×
902
            queryset = queryset.filter(Q(unallocated_stock__gt=0))
×
903
        else:
904
            queryset = queryset.filter(Q(unallocated_stock__lte=0))
×
905

906
        return queryset
×
907

908
    convert_from = rest_filters.ModelChoiceFilter(label="Can convert from", queryset=Part.objects.all(), method='filter_convert_from')
1✔
909

910
    def filter_convert_from(self, queryset, name, part):
1✔
911
        """Limit the queryset to valid conversion options for the specified part"""
912
        conversion_options = part.get_conversion_options()
1✔
913

914
        queryset = queryset.filter(pk__in=conversion_options)
1✔
915

916
        return queryset
1✔
917

918
    exclude_tree = rest_filters.ModelChoiceFilter(label="Exclude Part tree", queryset=Part.objects.all(), method='filter_exclude_tree')
1✔
919

920
    def filter_exclude_tree(self, queryset, name, part):
1✔
921
        """Exclude all parts and variants 'down' from the specified part from the queryset"""
922

923
        children = part.get_descendants(include_self=True)
×
924

925
        queryset = queryset.exclude(id__in=children)
×
926

927
        return queryset
×
928

929
    ancestor = rest_filters.ModelChoiceFilter(label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor')
1✔
930

931
    def filter_ancestor(self, queryset, name, part):
1✔
932
        """Limit queryset to descendants of the specified ancestor part"""
933

934
        descendants = part.get_descendants(include_self=False)
1✔
935
        queryset = queryset.filter(id__in=descendants)
1✔
936

937
        return queryset
1✔
938

939
    variant_of = rest_filters.ModelChoiceFilter(label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of')
1✔
940

941
    def filter_variant_of(self, queryset, name, part):
1✔
942
        """Limit queryset to direct children (variants) of the specified part"""
943

944
        queryset = queryset.filter(id__in=part.get_children())
1✔
945
        return queryset
1✔
946

947
    in_bom_for = rest_filters.ModelChoiceFilter(label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom')
1✔
948

949
    def filter_in_bom(self, queryset, name, part):
1✔
950
        """Limit queryset to parts in the BOM for the specified part"""
951

952
        bom_parts = part.get_parts_in_bom()
1✔
953
        queryset = queryset.filter(id__in=[p.pk for p in bom_parts])
1✔
954
        return queryset
1✔
955

956
    has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing")
1✔
957

958
    def filter_has_pricing(self, queryset, name, value):
1✔
959
        """Filter the queryset based on whether pricing information is available for the sub_part"""
960

961
        value = str2bool(value)
×
962

963
        q_a = Q(pricing_data=None)
×
964
        q_b = Q(pricing_data__overall_min=None, pricing_data__overall_max=None)
×
965

966
        if value:
×
967
            queryset = queryset.exclude(q_a | q_b)
×
968
        else:
969
            queryset = queryset.filter(q_a | q_b)
×
970

971
        return queryset
×
972

973
    stocktake = rest_filters.BooleanFilter(label="Has stocktake", method='filter_has_stocktake')
1✔
974

975
    def filter_has_stocktake(self, queryset, name, value):
1✔
976
        """Filter the queryset based on whether stocktake data is available"""
977

978
        value = str2bool(value)
×
979

980
        if (value):
×
981
            queryset = queryset.exclude(last_stocktake=None)
×
982
        else:
983
            queryset = queryset.filter(last_stocktake=None)
×
984

985
        return queryset
×
986

987
    is_template = rest_filters.BooleanFilter()
1✔
988

989
    assembly = rest_filters.BooleanFilter()
1✔
990

991
    component = rest_filters.BooleanFilter()
1✔
992

993
    trackable = rest_filters.BooleanFilter()
1✔
994

995
    purchaseable = rest_filters.BooleanFilter()
1✔
996

997
    salable = rest_filters.BooleanFilter()
1✔
998

999
    active = rest_filters.BooleanFilter()
1✔
1000

1001
    virtual = rest_filters.BooleanFilter()
1✔
1002

1003

1004
class PartMixin:
1✔
1005
    """Mixin class for Part API endpoints"""
1006
    serializer_class = part_serializers.PartSerializer
1✔
1007
    queryset = Part.objects.all()
1✔
1008

1009
    starred_parts = None
1✔
1010

1011
    is_create = False
1✔
1012

1013
    def get_queryset(self, *args, **kwargs):
1✔
1014
        """Return an annotated queryset object for the PartDetail endpoint"""
1015
        queryset = super().get_queryset(*args, **kwargs)
1✔
1016

1017
        queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
1✔
1018

1019
        return queryset
1✔
1020

1021
    def get_serializer(self, *args, **kwargs):
1✔
1022
        """Return a serializer instance for this endpoint"""
1023
        # Ensure the request context is passed through
1024
        kwargs['context'] = self.get_serializer_context()
1✔
1025

1026
        # Indicate that we can create a new Part via this endpoint
1027
        kwargs['create'] = self.is_create
1✔
1028

1029
        # Pass a list of "starred" parts to the current user to the serializer
1030
        # We do this to reduce the number of database queries required!
1031
        if self.starred_parts is None and self.request is not None:
1✔
1032
            self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
1✔
1033

1034
        kwargs['starred_parts'] = self.starred_parts
1✔
1035

1036
        try:
1✔
1037
            params = self.request.query_params
1✔
1038

1039
            kwargs['parameters'] = str2bool(params.get('parameters', None))
1✔
1040
            kwargs['category_detail'] = str2bool(params.get('category_detail', False))
1✔
1041

1042
        except AttributeError:
1✔
1043
            pass
1✔
1044

1045
        return self.serializer_class(*args, **kwargs)
1✔
1046

1047
    def get_serializer_context(self):
1✔
1048
        """Extend serializer context data"""
1049
        context = super().get_serializer_context()
1✔
1050
        context['request'] = self.request
1✔
1051

1052
        return context
1✔
1053

1054

1055
class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
1✔
1056
    """API endpoint for accessing a list of Part objects, or creating a new Part instance"""
1057

1058
    filterset_class = PartFilter
1✔
1059
    is_create = True
1✔
1060

1061
    def download_queryset(self, queryset, export_format):
1✔
1062
        """Download the filtered queryset as a data file"""
1063
        dataset = PartResource().export(queryset=queryset)
1✔
1064

1065
        filedata = dataset.export(export_format)
1✔
1066
        filename = f"InvenTree_Parts.{export_format}"
1✔
1067

1068
        return DownloadFile(filedata, filename)
1✔
1069

1070
    def list(self, request, *args, **kwargs):
1✔
1071
        """Overide the 'list' method, as the PartCategory objects are very expensive to serialize!
1072

1073
        So we will serialize them first, and keep them in memory, so that they do not have to be serialized multiple times...
1074
        """
1075
        queryset = self.filter_queryset(self.get_queryset())
1✔
1076

1077
        page = self.paginate_queryset(queryset)
1✔
1078

1079
        if page is not None:
1✔
1080
            serializer = self.get_serializer(page, many=True)
1✔
1081
        else:
1082
            serializer = self.get_serializer(queryset, many=True)
1✔
1083

1084
        data = serializer.data
1✔
1085

1086
        """
1087
        Determine the response type based on the request.
1088
        a) For HTTP requests (e.g. via the browseable API) return a DRF response
1089
        b) For AJAX requests, simply return a JSON rendered response.
1090
        """
1091
        if page is not None:
1✔
1092
            return self.get_paginated_response(data)
1✔
1093
        elif request.is_ajax():
1✔
1094
            return JsonResponse(data, safe=False)
×
1095
        else:
1096
            return Response(data)
1✔
1097

1098
    def filter_queryset(self, queryset):
1✔
1099
        """Perform custom filtering of the queryset"""
1100
        params = self.request.query_params
1✔
1101

1102
        queryset = super().filter_queryset(queryset)
1✔
1103

1104
        # Exclude specific part ID values?
1105
        exclude_id = []
1✔
1106

1107
        for key in ['exclude_id', 'exclude_id[]']:
1✔
1108
            if key in params:
1✔
1109
                exclude_id += params.getlist(key, [])
×
1110

1111
        if exclude_id:
1✔
1112

1113
            id_values = []
×
1114

1115
            for val in exclude_id:
×
1116
                try:
×
1117
                    # pk values must be integer castable
1118
                    val = int(val)
×
1119
                    id_values.append(val)
×
1120
                except ValueError:
×
1121
                    pass
×
1122

1123
            queryset = queryset.exclude(pk__in=id_values)
×
1124

1125
        # Filter by whether the BOM has been validated (or not)
1126
        bom_valid = params.get('bom_valid', None)
1✔
1127

1128
        # TODO: Querying bom_valid status may be quite expensive
1129
        # TODO: (It needs to be profiled!)
1130
        # TODO: It might be worth caching the bom_valid status to a database column
1131

1132
        if bom_valid is not None:
1✔
1133

1134
            bom_valid = str2bool(bom_valid)
×
1135

1136
            # Limit queryset to active assemblies
1137
            queryset = queryset.filter(active=True, assembly=True)
×
1138

1139
            pks = []
×
1140

1141
            for part in queryset:
×
1142
                if part.is_bom_valid() == bom_valid:
×
1143
                    pks.append(part.pk)
×
1144

1145
            queryset = queryset.filter(pk__in=pks)
×
1146

1147
        # Filter by 'related' parts?
1148
        related = params.get('related', None)
1✔
1149
        exclude_related = params.get('exclude_related', None)
1✔
1150

1151
        if related is not None or exclude_related is not None:
1✔
1152
            try:
1✔
1153
                pk = related if related is not None else exclude_related
1✔
1154
                pk = int(pk)
1✔
1155

1156
                related_part = Part.objects.get(pk=pk)
1✔
1157

1158
                part_ids = set()
1✔
1159

1160
                # Return any relationship which points to the part in question
1161
                relation_filter = Q(part_1=related_part) | Q(part_2=related_part)
1✔
1162

1163
                for relation in PartRelated.objects.filter(relation_filter):
1✔
1164

1165
                    if relation.part_1.pk != pk:
1✔
1166
                        part_ids.add(relation.part_1.pk)
1✔
1167

1168
                    if relation.part_2.pk != pk:
1✔
1169
                        part_ids.add(relation.part_2.pk)
1✔
1170

1171
                if related is not None:
1✔
1172
                    # Only return related results
1173
                    queryset = queryset.filter(pk__in=[pk for pk in part_ids])
1✔
1174
                elif exclude_related is not None:
×
1175
                    # Exclude related results
1176
                    queryset = queryset.exclude(pk__in=[pk for pk in part_ids])
×
1177

1178
            except (ValueError, Part.DoesNotExist):
×
1179
                pass
×
1180

1181
        # Filter by 'starred' parts?
1182
        starred = params.get('starred', None)
1✔
1183

1184
        if starred is not None:
1✔
1185
            starred = str2bool(starred)
×
1186
            starred_parts = [star.part.pk for star in self.request.user.starred_parts.all()]
×
1187

1188
            if starred:
×
1189
                queryset = queryset.filter(pk__in=starred_parts)
×
1190
            else:
1191
                queryset = queryset.exclude(pk__in=starred_parts)
×
1192

1193
        # Cascade? (Default = True)
1194
        cascade = str2bool(params.get('cascade', True))
1✔
1195

1196
        # Does the user wish to filter by category?
1197
        cat_id = params.get('category', None)
1✔
1198

1199
        if cat_id is not None:
1✔
1200
            # Category has been specified!
1201
            if isNull(cat_id):
1✔
1202
                # A 'null' category is the top-level category
1203
                if not cascade:
×
1204
                    # Do not cascade, only list parts in the top-level category
1205
                    queryset = queryset.filter(category=None)
×
1206

1207
            else:
1208
                try:
1✔
1209
                    category = PartCategory.objects.get(pk=cat_id)
1✔
1210

1211
                    # If '?cascade=true' then include parts which exist in sub-categories
1212
                    if cascade:
1✔
1213
                        queryset = queryset.filter(category__in=category.getUniqueChildren())
1✔
1214
                    # Just return parts directly in the requested category
1215
                    else:
1216
                        queryset = queryset.filter(category=cat_id)
×
1217
                except (ValueError, PartCategory.DoesNotExist):
×
1218
                    pass
×
1219

1220
        # Filer by 'depleted_stock' status -> has no stock and stock items
1221
        depleted_stock = params.get('depleted_stock', None)
1✔
1222

1223
        if depleted_stock is not None:
1✔
1224
            depleted_stock = str2bool(depleted_stock)
×
1225

1226
            if depleted_stock:
×
1227
                queryset = queryset.filter(Q(in_stock=0) & ~Q(stock_item_count=0))
×
1228

1229
        # Filter by "parts which need stock to complete build"
1230
        stock_to_build = params.get('stock_to_build', None)
1✔
1231

1232
        # TODO: This is super expensive, database query wise...
1233
        # TODO: Need to figure out a cheaper way of making this filter query
1234

1235
        if stock_to_build is not None:
1✔
1236
            # Get active builds
1237
            builds = Build.objects.filter(status__in=BuildStatus.ACTIVE_CODES)
×
1238
            # Store parts with builds needing stock
1239
            parts_needed_to_complete_builds = []
×
1240
            # Filter required parts
1241
            for build in builds:
×
1242
                parts_needed_to_complete_builds += [part.pk for part in build.required_parts_to_complete_build]
×
1243

1244
            queryset = queryset.filter(pk__in=parts_needed_to_complete_builds)
×
1245

1246
        return queryset
1✔
1247

1248
    filter_backends = [
1✔
1249
        DjangoFilterBackend,
1250
        filters.SearchFilter,
1251
        InvenTreeOrderingFilter,
1252
    ]
1253

1254
    ordering_fields = [
1✔
1255
        'name',
1256
        'creation_date',
1257
        'IPN',
1258
        'in_stock',
1259
        'total_in_stock',
1260
        'unallocated_stock',
1261
        'category',
1262
        'last_stocktake',
1263
    ]
1264

1265
    # Default ordering
1266
    ordering = 'name'
1✔
1267

1268
    search_fields = [
1✔
1269
        'name',
1270
        'description',
1271
        'IPN',
1272
        'revision',
1273
        'keywords',
1274
        'category__name',
1275
        'manufacturer_parts__MPN',
1276
        'supplier_parts__SKU',
1277
    ]
1278

1279

1280
class PartDetail(PartMixin, RetrieveUpdateDestroyAPI):
1✔
1281
    """API endpoint for detail view of a single Part object."""
1282

1283
    def destroy(self, request, *args, **kwargs):
1✔
1284
        """Delete a Part instance via the API
1285

1286
        - If the part is 'active' it cannot be deleted
1287
        - It must first be marked as 'inactive'
1288
        """
1289
        part = Part.objects.get(pk=int(kwargs['pk']))
1✔
1290
        # Check if inactive
1291
        if not part.active:
1✔
1292
            # Delete
1293
            return super(PartDetail, self).destroy(request, *args, **kwargs)
1✔
1294
        else:
1295
            # Return 405 error
1296
            message = 'Part is active: cannot delete'
1✔
1297
            return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message)
1✔
1298

1299
    def update(self, request, *args, **kwargs):
1✔
1300
        """Custom update functionality for Part instance.
1301

1302
        - If the 'starred' field is provided, update the 'starred' status against current user
1303
        """
1304
        # Clean input data
1305
        data = self.clean_data(request.data)
1✔
1306

1307
        if 'starred' in data:
1✔
1308
            starred = str2bool(data.get('starred', False))
×
1309

1310
            self.get_object().set_starred(request.user, starred)
×
1311

1312
        response = super().update(request, *args, **kwargs)
1✔
1313

1314
        return response
1✔
1315

1316

1317
class PartRelatedList(ListCreateAPI):
1✔
1318
    """API endpoint for accessing a list of PartRelated objects."""
1319

1320
    queryset = PartRelated.objects.all()
1✔
1321
    serializer_class = part_serializers.PartRelationSerializer
1✔
1322

1323
    def filter_queryset(self, queryset):
1✔
1324
        """Custom queryset filtering"""
1325
        queryset = super().filter_queryset(queryset)
×
1326

1327
        params = self.request.query_params
×
1328

1329
        # Add a filter for "part" - we can filter either part_1 or part_2
1330
        part = params.get('part', None)
×
1331

1332
        if part is not None:
×
1333
            try:
×
1334
                part = Part.objects.get(pk=part)
×
1335

1336
                queryset = queryset.filter(Q(part_1=part) | Q(part_2=part))
×
1337

1338
            except (ValueError, Part.DoesNotExist):
×
1339
                pass
×
1340

1341
        return queryset
×
1342

1343

1344
class PartRelatedDetail(RetrieveUpdateDestroyAPI):
1✔
1345
    """API endpoint for accessing detail view of a PartRelated object."""
1346

1347
    queryset = PartRelated.objects.all()
1✔
1348
    serializer_class = part_serializers.PartRelationSerializer
1✔
1349

1350

1351
class PartParameterTemplateList(ListCreateAPI):
1✔
1352
    """API endpoint for accessing a list of PartParameterTemplate objects.
1353

1354
    - GET: Return list of PartParameterTemplate objects
1355
    - POST: Create a new PartParameterTemplate object
1356
    """
1357

1358
    queryset = PartParameterTemplate.objects.all()
1✔
1359
    serializer_class = part_serializers.PartParameterTemplateSerializer
1✔
1360

1361
    filter_backends = [
1✔
1362
        DjangoFilterBackend,
1363
        filters.OrderingFilter,
1364
        filters.SearchFilter,
1365
    ]
1366

1367
    filterset_fields = [
1✔
1368
        'name',
1369
    ]
1370

1371
    search_fields = [
1✔
1372
        'name',
1373
    ]
1374

1375
    def filter_queryset(self, queryset):
1✔
1376
        """Custom filtering for the PartParameterTemplate API."""
1377
        queryset = super().filter_queryset(queryset)
×
1378

1379
        params = self.request.query_params
×
1380

1381
        # Filtering against a "Part" - return only parameter templates which are referenced by a part
1382
        part = params.get('part', None)
×
1383

1384
        if part is not None:
×
1385

1386
            try:
×
1387
                part = Part.objects.get(pk=part)
×
1388
                parameters = PartParameter.objects.filter(part=part)
×
1389
                template_ids = parameters.values_list('template').distinct()
×
1390
                queryset = queryset.filter(pk__in=[el[0] for el in template_ids])
×
1391
            except (ValueError, Part.DoesNotExist):
×
1392
                pass
×
1393

1394
        # Filtering against a "PartCategory" - return only parameter templates which are referenced by parts in this category
1395
        category = params.get('category', None)
×
1396

1397
        if category is not None:
×
1398

1399
            try:
×
1400
                category = PartCategory.objects.get(pk=category)
×
1401
                cats = category.get_descendants(include_self=True)
×
1402
                parameters = PartParameter.objects.filter(part__category__in=cats)
×
1403
                template_ids = parameters.values_list('template').distinct()
×
1404
                queryset = queryset.filter(pk__in=[el[0] for el in template_ids])
×
1405
            except (ValueError, PartCategory.DoesNotExist):
×
1406
                pass
×
1407

1408
        return queryset
×
1409

1410

1411
class PartParameterTemplateDetail(RetrieveUpdateDestroyAPI):
1✔
1412
    """API endpoint for accessing the detail view for a PartParameterTemplate object"""
1413

1414
    queryset = PartParameterTemplate.objects.all()
1✔
1415
    serializer_class = part_serializers.PartParameterTemplateSerializer
1✔
1416

1417

1418
class PartParameterTemplateMetadata(RetrieveUpdateAPI):
1✔
1419
    """API endpoint for viewing / updating PartParameterTemplate metadata."""
1420

1421
    def get_serializer(self, *args, **kwargs):
1✔
1422
        """Return a MetadataSerializer pointing to the referenced PartParameterTemplate instance"""
1423
        return MetadataSerializer(PartParameterTemplate, *args, **kwargs)
1✔
1424

1425
    queryset = PartParameterTemplate.objects.all()
1✔
1426

1427

1428
class PartParameterList(ListCreateAPI):
1✔
1429
    """API endpoint for accessing a list of PartParameter objects.
1430

1431
    - GET: Return list of PartParameter objects
1432
    - POST: Create a new PartParameter object
1433
    """
1434

1435
    queryset = PartParameter.objects.all()
1✔
1436
    serializer_class = part_serializers.PartParameterSerializer
1✔
1437

1438
    def get_serializer(self, *args, **kwargs):
1✔
1439
        """Return the serializer instance for this API endpoint.
1440

1441
        If requested, extra detail fields are annotated to the queryset:
1442
        - template_detail
1443
        """
1444

1445
        try:
1✔
1446
            kwargs['template_detail'] = str2bool(self.request.GET.get('template_detail', True))
1✔
1447
        except AttributeError:
1✔
1448
            pass
1✔
1449

1450
        return self.serializer_class(*args, **kwargs)
1✔
1451

1452
    filter_backends = [
1✔
1453
        DjangoFilterBackend
1454
    ]
1455

1456
    filterset_fields = [
1✔
1457
        'part',
1458
        'template',
1459
    ]
1460

1461

1462
class PartParameterDetail(RetrieveUpdateDestroyAPI):
1✔
1463
    """API endpoint for detail view of a single PartParameter object."""
1464

1465
    queryset = PartParameter.objects.all()
1✔
1466
    serializer_class = part_serializers.PartParameterSerializer
1✔
1467

1468

1469
class PartStocktakeFilter(rest_filters.FilterSet):
1✔
1470
    """Custom fitler for the PartStocktakeList endpoint"""
1471

1472
    class Meta:
1✔
1473
        """Metaclass options"""
1474

1475
        model = PartStocktake
1✔
1476
        fields = [
1✔
1477
            'part',
1478
            'user',
1479
        ]
1480

1481

1482
class PartStocktakeList(ListCreateAPI):
1✔
1483
    """API endpoint for listing part stocktake information"""
1484

1485
    queryset = PartStocktake.objects.all()
1✔
1486
    serializer_class = part_serializers.PartStocktakeSerializer
1✔
1487
    filterset_class = PartStocktakeFilter
1✔
1488

1489
    def get_serializer_context(self):
1✔
1490
        """Extend serializer context data"""
1491
        context = super().get_serializer_context()
1✔
1492
        context['request'] = self.request
1✔
1493

1494
        return context
1✔
1495

1496
    filter_backends = [
1✔
1497
        DjangoFilterBackend,
1498
        filters.OrderingFilter,
1499
    ]
1500

1501
    ordering_fields = [
1✔
1502
        'part',
1503
        'item_count',
1504
        'quantity',
1505
        'date',
1506
        'user',
1507
        'pk',
1508
    ]
1509

1510
    # Reverse date ordering by default
1511
    ordering = '-pk'
1✔
1512

1513

1514
class PartStocktakeDetail(RetrieveUpdateDestroyAPI):
1✔
1515
    """Detail API endpoint for a single PartStocktake instance.
1516

1517
    Note: Only staff (admin) users can access this endpoint.
1518
    """
1519

1520
    queryset = PartStocktake.objects.all()
1✔
1521
    serializer_class = part_serializers.PartStocktakeSerializer
1✔
1522

1523

1524
class PartStocktakeReportList(ListAPI):
1✔
1525
    """API endpoint for listing part stocktake report information"""
1526

1527
    queryset = PartStocktakeReport.objects.all()
1✔
1528
    serializer_class = part_serializers.PartStocktakeReportSerializer
1✔
1529

1530
    filter_backends = [
1✔
1531
        DjangoFilterBackend,
1532
        filters.OrderingFilter,
1533
    ]
1534

1535
    ordering_fields = [
1✔
1536
        'date',
1537
        'pk',
1538
    ]
1539

1540
    # Newest first, by default
1541
    ordering = '-pk'
1✔
1542

1543

1544
class PartStocktakeReportGenerate(CreateAPI):
1✔
1545
    """API endpoint for manually generating a new PartStocktakeReport"""
1546

1547
    serializer_class = part_serializers.PartStocktakeReportGenerateSerializer
1✔
1548

1549
    permission_classes = [
1✔
1550
        permissions.IsAuthenticated,
1551
        RolePermission,
1552
    ]
1553

1554
    role_required = 'stocktake'
1✔
1555

1556
    def get_serializer_context(self):
1✔
1557
        """Extend serializer context data"""
1558
        context = super().get_serializer_context()
1✔
1559
        context['request'] = self.request
1✔
1560

1561
        return context
1✔
1562

1563

1564
class BomFilter(rest_filters.FilterSet):
1✔
1565
    """Custom filters for the BOM list."""
1566

1567
    class Meta:
1✔
1568
        """Metaclass options"""
1569

1570
        model = BomItem
1✔
1571
        fields = [
1✔
1572
            'optional',
1573
            'consumable',
1574
            'inherited',
1575
            'allow_variants',
1576
            'validated',
1577
        ]
1578

1579
    # Filters for linked 'part'
1580
    part_active = rest_filters.BooleanFilter(label='Master part is active', field_name='part__active')
1✔
1581
    part_trackable = rest_filters.BooleanFilter(label='Master part is trackable', field_name='part__trackable')
1✔
1582

1583
    # Filters for linked 'sub_part'
1584
    sub_part_trackable = rest_filters.BooleanFilter(label='Sub part is trackable', field_name='sub_part__trackable')
1✔
1585
    sub_part_assembly = rest_filters.BooleanFilter(label='Sub part is an assembly', field_name='sub_part__assembly')
1✔
1586

1587
    available_stock = rest_filters.BooleanFilter(label="Has available stock", method="filter_available_stock")
1✔
1588

1589
    def filter_available_stock(self, queryset, name, value):
1✔
1590
        """Filter the queryset based on whether each line item has any available stock"""
1591

1592
        value = str2bool(value)
×
1593

1594
        if value:
×
1595
            queryset = queryset.filter(available_stock__gt=0)
×
1596
        else:
1597
            queryset = queryset.filter(available_stock=0)
×
1598

1599
        return queryset
×
1600

1601
    on_order = rest_filters.BooleanFilter(label="On order", method="filter_on_order")
1✔
1602

1603
    def filter_on_order(self, queryset, name, value):
1✔
1604
        """Filter the queryset based on whether each line item has any stock on order"""
1605

1606
        value = str2bool(value)
×
1607

1608
        if value:
×
1609
            queryset = queryset.filter(on_order__gt=0)
×
1610
        else:
1611
            queryset = queryset.filter(on_order=0)
×
1612

1613
        return queryset
×
1614

1615
    has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing")
1✔
1616

1617
    def filter_has_pricing(self, queryset, name, value):
1✔
1618
        """Filter the queryset based on whether pricing information is available for the sub_part"""
1619

1620
        value = str2bool(value)
×
1621

1622
        q_a = Q(sub_part__pricing_data=None)
×
1623
        q_b = Q(sub_part__pricing_data__overall_min=None, sub_part__pricing_data__overall_max=None)
×
1624

1625
        if value:
×
1626
            queryset = queryset.exclude(q_a | q_b)
×
1627
        else:
1628
            queryset = queryset.filter(q_a | q_b)
×
1629

1630
        return queryset
×
1631

1632

1633
class BomMixin:
1✔
1634
    """Mixin class for BomItem API endpoints"""
1635

1636
    serializer_class = part_serializers.BomItemSerializer
1✔
1637
    queryset = BomItem.objects.all()
1✔
1638

1639
    def get_serializer(self, *args, **kwargs):
1✔
1640
        """Return the serializer instance for this API endpoint
1641

1642
        If requested, extra detail fields are annotated to the queryset:
1643
        - part_detail
1644
        - sub_part_detail
1645
        """
1646

1647
        # Do we wish to include extra detail?
1648
        try:
1✔
1649
            kwargs['part_detail'] = str2bool(self.request.GET.get('part_detail', None))
1✔
1650
        except AttributeError:
1✔
1651
            pass
1✔
1652

1653
        try:
1✔
1654
            kwargs['sub_part_detail'] = str2bool(self.request.GET.get('sub_part_detail', None))
1✔
1655
        except AttributeError:
1✔
1656
            pass
1✔
1657

1658
        # Ensure the request context is passed through!
1659
        kwargs['context'] = self.get_serializer_context()
1✔
1660

1661
        return self.serializer_class(*args, **kwargs)
1✔
1662

1663
    def get_queryset(self, *args, **kwargs):
1✔
1664
        """Return the queryset object for this endpoint"""
1665
        queryset = super().get_queryset(*args, **kwargs)
1✔
1666

1667
        queryset = self.get_serializer_class().setup_eager_loading(queryset)
1✔
1668
        queryset = self.get_serializer_class().annotate_queryset(queryset)
1✔
1669

1670
        return queryset
1✔
1671

1672

1673
class BomList(BomMixin, ListCreateDestroyAPIView):
1✔
1674
    """API endpoint for accessing a list of BomItem objects.
1675

1676
    - GET: Return list of BomItem objects
1677
    - POST: Create a new BomItem object
1678
    """
1679

1680
    filterset_class = BomFilter
1✔
1681

1682
    def list(self, request, *args, **kwargs):
1✔
1683
        """Return serialized list response for this endpoint"""
1684

1685
        queryset = self.filter_queryset(self.get_queryset())
1✔
1686

1687
        page = self.paginate_queryset(queryset)
1✔
1688

1689
        if page is not None:
1✔
1690
            serializer = self.get_serializer(page, many=True)
×
1691
        else:
1692
            serializer = self.get_serializer(queryset, many=True)
1✔
1693

1694
        data = serializer.data
1✔
1695

1696
        """
1697
        Determine the response type based on the request.
1698
        a) For HTTP requests (e.g. via the browseable API) return a DRF response
1699
        b) For AJAX requests, simply return a JSON rendered response.
1700
        """
1701
        if page is not None:
1✔
1702
            return self.get_paginated_response(data)
×
1703
        elif request.is_ajax():
1✔
1704
            return JsonResponse(data, safe=False)
×
1705
        else:
1706
            return Response(data)
1✔
1707

1708
    def filter_queryset(self, queryset):
1✔
1709
        """Custom query filtering for the BomItem list API"""
1710
        queryset = super().filter_queryset(queryset)
1✔
1711

1712
        params = self.request.query_params
1✔
1713

1714
        # Filter by part?
1715
        part = params.get('part', None)
1✔
1716

1717
        if part is not None:
1✔
1718
            """
1719
            If we are filtering by "part", there are two cases to consider:
1720

1721
            a) Bom items which are defined for *this* part
1722
            b) Inherited parts which are defined for a *parent* part
1723

1724
            So we need to construct two queries!
1725
            """
1726

1727
            # First, check that the part is actually valid!
1728
            try:
1✔
1729
                part = Part.objects.get(pk=part)
1✔
1730

1731
                queryset = queryset.filter(part.get_bom_item_filter())
1✔
1732

1733
            except (ValueError, Part.DoesNotExist):
×
1734
                pass
×
1735

1736
        """
1737
        Filter by 'uses'?
1738

1739
        Here we pass a part ID and return BOM items for any assemblies which "use" (or "require") that part.
1740

1741
        There are multiple ways that an assembly can "use" a sub-part:
1742

1743
        A) Directly specifying the sub_part in a BomItem field
1744
        B) Specifing a "template" part with inherited=True
1745
        C) Allowing variant parts to be substituted
1746
        D) Allowing direct substitute parts to be specified
1747

1748
        - BOM items which are "inherited" by parts which are variants of the master BomItem
1749
        """
1750
        uses = params.get('uses', None)
1✔
1751

1752
        if uses is not None:
1✔
1753

1754
            try:
1✔
1755
                # Extract the part we are interested in
1756
                uses_part = Part.objects.get(pk=uses)
1✔
1757

1758
                queryset = queryset.filter(uses_part.get_used_in_bom_item_filter())
1✔
1759

1760
            except (ValueError, Part.DoesNotExist):
×
1761
                pass
×
1762

1763
        return queryset
1✔
1764

1765
    filter_backends = [
1✔
1766
        DjangoFilterBackend,
1767
        filters.SearchFilter,
1768
        InvenTreeOrderingFilter,
1769
    ]
1770

1771
    search_fields = [
1✔
1772
        'reference',
1773
        'sub_part__name',
1774
        'sub_part__description',
1775
        'sub_part__IPN',
1776
        'sub_part__revision',
1777
        'sub_part__keywords',
1778
        'sub_part__category__name',
1779
    ]
1780

1781
    ordering_fields = [
1✔
1782
        'quantity',
1783
        'sub_part',
1784
        'available_stock',
1785
    ]
1786

1787
    ordering_field_aliases = {
1✔
1788
        'sub_part': 'sub_part__name',
1789
    }
1790

1791

1792
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI):
1✔
1793
    """API endpoint for detail view of a single BomItem object."""
1794
    pass
1✔
1795

1796

1797
class BomImportUpload(CreateAPI):
1✔
1798
    """API endpoint for uploading a complete Bill of Materials.
1799

1800
    It is assumed that the BOM has been extracted from a file using the BomExtract endpoint.
1801
    """
1802

1803
    queryset = Part.objects.all()
1✔
1804
    serializer_class = part_serializers.BomImportUploadSerializer
1✔
1805

1806
    def create(self, request, *args, **kwargs):
1✔
1807
        """Custom create function to return the extracted data."""
1808
        # Clean up input data
1809
        data = self.clean_data(request.data)
1✔
1810

1811
        serializer = self.get_serializer(data=data)
1✔
1812
        serializer.is_valid(raise_exception=True)
1✔
1813
        self.perform_create(serializer)
×
1814
        headers = self.get_success_headers(serializer.data)
×
1815

1816
        data = serializer.extract_data()
×
1817

1818
        return Response(data, status=status.HTTP_201_CREATED, headers=headers)
×
1819

1820

1821
class BomImportExtract(CreateAPI):
1✔
1822
    """API endpoint for extracting BOM data from a BOM file."""
1823

1824
    queryset = Part.objects.none()
1✔
1825
    serializer_class = part_serializers.BomImportExtractSerializer
1✔
1826

1827

1828
class BomImportSubmit(CreateAPI):
1✔
1829
    """API endpoint for submitting BOM data from a BOM file."""
1830

1831
    queryset = BomItem.objects.none()
1✔
1832
    serializer_class = part_serializers.BomImportSubmitSerializer
1✔
1833

1834

1835
class BomItemValidate(UpdateAPI):
1✔
1836
    """API endpoint for validating a BomItem."""
1837

1838
    class BomItemValidationSerializer(serializers.Serializer):
1✔
1839
        """Simple serializer for passing a single boolean field"""
1840
        valid = serializers.BooleanField(default=False)
1✔
1841

1842
    queryset = BomItem.objects.all()
1✔
1843
    serializer_class = BomItemValidationSerializer
1✔
1844

1845
    def update(self, request, *args, **kwargs):
1✔
1846
        """Perform update request."""
1847
        partial = kwargs.pop('partial', False)
×
1848

1849
        # Clean up input data
1850
        data = self.clean_data(request.data)
×
1851
        valid = data.get('valid', False)
×
1852

1853
        instance = self.get_object()
×
1854

1855
        serializer = self.get_serializer(instance, data=data, partial=partial)
×
1856
        serializer.is_valid(raise_exception=True)
×
1857

1858
        if type(instance) == BomItem:
×
1859
            instance.validate_hash(valid)
×
1860

1861
        return Response(serializer.data)
×
1862

1863

1864
class BomItemSubstituteList(ListCreateAPI):
1✔
1865
    """API endpoint for accessing a list of BomItemSubstitute objects."""
1866

1867
    serializer_class = part_serializers.BomItemSubstituteSerializer
1✔
1868
    queryset = BomItemSubstitute.objects.all()
1✔
1869

1870
    filter_backends = [
1✔
1871
        DjangoFilterBackend,
1872
        filters.SearchFilter,
1873
        filters.OrderingFilter,
1874
    ]
1875

1876
    filterset_fields = [
1✔
1877
        'part',
1878
        'bom_item',
1879
    ]
1880

1881

1882
class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI):
1✔
1883
    """API endpoint for detail view of a single BomItemSubstitute object."""
1884

1885
    queryset = BomItemSubstitute.objects.all()
1✔
1886
    serializer_class = part_serializers.BomItemSubstituteSerializer
1✔
1887

1888

1889
class BomItemMetadata(RetrieveUpdateAPI):
1✔
1890
    """API endpoint for viewing / updating PartBOM metadata."""
1891

1892
    def get_serializer(self, *args, **kwargs):
1✔
1893
        """Return a MetadataSerializer pointing to the referenced PartCategory instance"""
1894
        return MetadataSerializer(BomItem, *args, **kwargs)
1✔
1895

1896
    queryset = BomItem.objects.all()
1✔
1897

1898

1899
part_api_urls = [
1✔
1900

1901
    # Base URL for PartCategory API endpoints
1902
    re_path(r'^category/', include([
1903
        re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
1904

1905
        re_path(r'^parameters/', include([
1906
            re_path(r'^(?P<pk>\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'),
1907
            re_path(r'^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
1908
        ])),
1909

1910
        # Category detail endpoints
1911
        path(r'<int:pk>/', include([
1912

1913
            re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'),
1914

1915
            # PartCategory detail endpoint
1916
            re_path(r'^.*$', CategoryDetail.as_view(), name='api-part-category-detail'),
1917
        ])),
1918

1919
        path('', CategoryList.as_view(), name='api-part-category-list'),
1920
    ])),
1921

1922
    # Base URL for PartTestTemplate API endpoints
1923
    re_path(r'^test-template/', include([
1924
        path(r'<int:pk>/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'),
1925
        path('', PartTestTemplateList.as_view(), name='api-part-test-template-list'),
1926
    ])),
1927

1928
    # Base URL for PartAttachment API endpoints
1929
    re_path(r'^attachment/', include([
1930
        path(r'<int:pk>/', PartAttachmentDetail.as_view(), name='api-part-attachment-detail'),
1931
        path('', PartAttachmentList.as_view(), name='api-part-attachment-list'),
1932
    ])),
1933

1934
    # Base URL for part sale pricing
1935
    re_path(r'^sale-price/', include([
1936
        path(r'<int:pk>/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
1937
        re_path(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
1938
    ])),
1939

1940
    # Base URL for part internal pricing
1941
    re_path(r'^internal-price/', include([
1942
        path(r'<int:pk>/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
1943
        re_path(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
1944
    ])),
1945

1946
    # Base URL for PartRelated API endpoints
1947
    re_path(r'^related/', include([
1948
        path(r'<int:pk>/', PartRelatedDetail.as_view(), name='api-part-related-detail'),
1949
        re_path(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'),
1950
    ])),
1951

1952
    # Base URL for PartParameter API endpoints
1953
    re_path(r'^parameter/', include([
1954
        path('template/', include([
1955
            re_path(r'^(?P<pk>\d+)/', include([
1956
                re_path(r'^metadata/?', PartParameterTemplateMetadata.as_view(), name='api-part-parameter-template-metadata'),
1957
                re_path(r'^.*$', PartParameterTemplateDetail.as_view(), name='api-part-parameter-template-detail'),
1958
            ])),
1959
            re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
1960
        ])),
1961

1962
        path(r'<int:pk>/', PartParameterDetail.as_view(), name='api-part-parameter-detail'),
1963
        re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'),
1964
    ])),
1965

1966
    # Part stocktake data
1967
    re_path(r'^stocktake/', include([
1968

1969
        path(r'report/', include([
1970
            path('generate/', PartStocktakeReportGenerate.as_view(), name='api-part-stocktake-report-generate'),
1971
            re_path(r'^.*$', PartStocktakeReportList.as_view(), name='api-part-stocktake-report-list'),
1972
        ])),
1973

1974
        path(r'<int:pk>/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'),
1975
        re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'),
1976
    ])),
1977

1978
    re_path(r'^thumbs/', include([
1979
        path('', PartThumbs.as_view(), name='api-part-thumbs'),
1980
        re_path(r'^(?P<pk>\d+)/?', PartThumbsUpdate.as_view(), name='api-part-thumbs-update'),
1981
    ])),
1982

1983
    # BOM template
1984
    re_path(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='api-bom-upload-template'),
1985

1986
    path(r'<int:pk>/', include([
1987

1988
        # Endpoint for extra serial number information
1989
        re_path(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'),
1990

1991
        # Endpoint for future scheduling information
1992
        re_path(r'^scheduling/', PartScheduling.as_view(), name='api-part-scheduling'),
1993

1994
        re_path(r'^requirements/', PartRequirements.as_view(), name='api-part-requirements'),
1995

1996
        # Endpoint for duplicating a BOM for the specific Part
1997
        re_path(r'^bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'),
1998

1999
        # Endpoint for validating a BOM for the specific Part
2000
        re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
2001

2002
        # Part metadata
2003
        re_path(r'^metadata/', PartMetadata.as_view(), name='api-part-metadata'),
2004

2005
        # Part pricing
2006
        re_path(r'^pricing/', PartPricingDetail.as_view(), name='api-part-pricing'),
2007

2008
        # BOM download
2009
        re_path(r'^bom-download/?', views.BomDownload.as_view(), name='api-bom-download'),
2010

2011
        # Old pricing endpoint
2012
        re_path(r'^pricing2/', views.PartPricing.as_view(), name='part-pricing'),
2013

2014
        # Part detail endpoint
2015
        re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
2016
    ])),
2017

2018
    re_path(r'^.*$', PartList.as_view(), name='api-part-list'),
2019
]
2020

2021
bom_api_urls = [
1✔
2022

2023
    re_path(r'^substitute/', include([
2024

2025
        # Detail view
2026
        path(r'<int:pk>/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'),
2027

2028
        # Catch all
2029
        re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),
2030
    ])),
2031

2032
    # BOM Item Detail
2033
    path(r'<int:pk>/', include([
2034
        re_path(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),
2035
        re_path(r'^metadata/?', BomItemMetadata.as_view(), name='api-bom-item-metadata'),
2036
        re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
2037
    ])),
2038

2039
    # API endpoint URLs for importing BOM data
2040
    re_path(r'^import/upload/', BomImportUpload.as_view(), name='api-bom-import-upload'),
2041
    re_path(r'^import/extract/', BomImportExtract.as_view(), name='api-bom-import-extract'),
2042
    re_path(r'^import/submit/', BomImportSubmit.as_view(), name='api-bom-import-submit'),
2043

2044
    # Catch-all
2045
    re_path(r'^.*$', BomList.as_view(), name='api-bom-list'),
2046
]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc