InvenTree/InvenTree/part/api.py
Oliver 05d7159882
Pre-fill quantity on part ordering form (#3395)
* Pre-fill quantity on part ordering form

- Adds new API endpoint for requirement data
- Load data on the fly when launching ordering form

* Bump API version
2022-07-25 13:00:35 +10:00

2077 lines
68 KiB
Python

"""Provides a JSON API for the Part app."""
import datetime
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.db.models import Avg, Count, F, Max, Min, Q
from django.http import JsonResponse
from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from rest_framework import filters, serializers, status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
import order.models
from build.models import Build, BuildItem
from common.models import InvenTreeSetting
from company.models import Company, ManufacturerPart, SupplierPart
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
ListCreateDestroyAPIView)
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import DownloadFile, increment, isNull, str2bool
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI,
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
UpdateAPI)
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus)
from part.admin import PartResource
from plugin.serializers import MetadataSerializer
from stock.models import StockItem, StockLocation
from . import serializers as part_serializers
from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartCategory, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartParameter,
PartParameterTemplate, PartRelated, PartSellPriceBreak,
PartTestTemplate)
class CategoryList(ListCreateAPI):
"""API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects
- POST: Create a new PartCategory object
"""
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer
def get_serializer_context(self):
"""Add extra context data to the serializer for the PartCategoryList endpoint"""
ctx = super().get_serializer_context()
try:
ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
except AttributeError:
# Error is thrown if the view does not have an associated request
ctx['starred_categories'] = []
return ctx
def filter_queryset(self, queryset):
"""Custom filtering:
- Allow filtering by "null" parent to retrieve top-level part categories
"""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
cat_id = params.get('parent', None)
cascade = str2bool(params.get('cascade', False))
# Do not filter by category
if cat_id is None:
pass
# Look for top-level categories
elif isNull(cat_id):
if not cascade:
queryset = queryset.filter(parent=None)
else:
try:
category = PartCategory.objects.get(pk=cat_id)
if cascade:
parents = category.get_descendants(include_self=True)
parent_ids = [p.id for p in parents]
queryset = queryset.filter(parent__in=parent_ids)
else:
queryset = queryset.filter(parent=category)
except (ValueError, PartCategory.DoesNotExist):
pass
# Exclude PartCategory tree
exclude_tree = params.get('exclude_tree', None)
if exclude_tree is not None:
try:
cat = PartCategory.objects.get(pk=exclude_tree)
queryset = queryset.exclude(
pk__in=[c.pk for c in cat.get_descendants(include_self=True)]
)
except (ValueError, PartCategory.DoesNotExist):
pass
# Filter by "starred" status
starred = params.get('starred', None)
if starred is not None:
starred = str2bool(starred)
starred_categories = [star.category.pk for star in self.request.user.starred_categories.all()]
if starred:
queryset = queryset.filter(pk__in=starred_categories)
else:
queryset = queryset.exclude(pk__in=starred_categories)
return queryset
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = [
]
ordering_fields = [
'name',
'level',
'tree_id',
'lft',
]
# Use hierarchical ordering by default
ordering = [
'tree_id',
'lft',
'name'
]
search_fields = [
'name',
'description',
]
class CategoryDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single PartCategory object."""
serializer_class = part_serializers.CategorySerializer
queryset = PartCategory.objects.all()
def get_serializer_context(self):
"""Add extra context to the serializer for the CategoryDetail endpoint"""
ctx = super().get_serializer_context()
try:
ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
except AttributeError:
# Error is thrown if the view does not have an associated request
ctx['starred_categories'] = []
return ctx
def update(self, request, *args, **kwargs):
"""Perform 'update' function and mark this part as 'starred' (or not)"""
# Clean up input data
data = self.clean_data(request.data)
if 'starred' in data:
starred = str2bool(data.get('starred', False))
self.get_object().set_starred(request.user, starred)
response = super().update(request, *args, **kwargs)
return response
class CategoryMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating PartCategory metadata."""
def get_serializer(self, *args, **kwargs):
"""Return a MetadataSerializer pointing to the referenced PartCategory instance"""
return MetadataSerializer(PartCategory, *args, **kwargs)
queryset = PartCategory.objects.all()
class CategoryParameterList(ListCreateAPI):
"""API endpoint for accessing a list of PartCategoryParameterTemplate objects.
- GET: Return a list of PartCategoryParameterTemplate objects
"""
queryset = PartCategoryParameterTemplate.objects.all()
serializer_class = part_serializers.CategoryParameterTemplateSerializer
def get_queryset(self):
"""Custom filtering:
- Allow filtering by "null" parent to retrieve all categories parameter templates
- Allow filtering by category
- Allow traversing all parent categories
"""
queryset = super().get_queryset()
params = self.request.query_params
category = params.get('category', None)
if category is not None:
try:
category = PartCategory.objects.get(pk=category)
fetch_parent = str2bool(params.get('fetch_parent', True))
if fetch_parent:
parents = category.get_ancestors(include_self=True)
queryset = queryset.filter(category__in=[cat.pk for cat in parents])
else:
queryset = queryset.filter(category=category)
except (ValueError, PartCategory.DoesNotExist):
pass
return queryset
class CategoryParameterDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint fro the PartCategoryParameterTemplate model"""
queryset = PartCategoryParameterTemplate.objects.all()
serializer_class = part_serializers.CategoryParameterTemplateSerializer
class CategoryTree(ListAPI):
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategoryTree
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
]
# Order by tree level (top levels first) and then name
ordering = ['level', 'name']
class PartSalePriceDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for PartSellPriceBreak model."""
queryset = PartSellPriceBreak.objects.all()
serializer_class = part_serializers.PartSalePriceSerializer
class PartSalePriceList(ListCreateAPI):
"""API endpoint for list view of PartSalePriceBreak model."""
queryset = PartSellPriceBreak.objects.all()
serializer_class = part_serializers.PartSalePriceSerializer
filter_backends = [
DjangoFilterBackend
]
filterset_fields = [
'part',
]
class PartInternalPriceDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for PartInternalPriceBreak model."""
queryset = PartInternalPriceBreak.objects.all()
serializer_class = part_serializers.PartInternalPriceSerializer
class PartInternalPriceList(ListCreateAPI):
"""API endpoint for list view of PartInternalPriceBreak model."""
queryset = PartInternalPriceBreak.objects.all()
serializer_class = part_serializers.PartInternalPriceSerializer
permission_required = 'roles.sales_order.show'
filter_backends = [
DjangoFilterBackend
]
filterset_fields = [
'part',
]
class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a PartAttachment (file upload)."""
queryset = PartAttachment.objects.all()
serializer_class = part_serializers.PartAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [
'part',
]
class PartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for PartAttachment model."""
queryset = PartAttachment.objects.all()
serializer_class = part_serializers.PartAttachmentSerializer
class PartTestTemplateDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for PartTestTemplate model."""
queryset = PartTestTemplate.objects.all()
serializer_class = part_serializers.PartTestTemplateSerializer
class PartTestTemplateList(ListCreateAPI):
"""API endpoint for listing (and creating) a PartTestTemplate."""
queryset = PartTestTemplate.objects.all()
serializer_class = part_serializers.PartTestTemplateSerializer
def filter_queryset(self, queryset):
"""Filter the test list queryset.
If filtering by 'part', we include results for any parts "above" the specified part.
"""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
part = params.get('part', None)
# Filter by part
if part:
try:
part = Part.objects.get(pk=part)
queryset = queryset.filter(part__in=part.get_ancestors(include_self=True))
except (ValueError, Part.DoesNotExist):
pass
# Filter by 'required' status
required = params.get('required', None)
if required is not None:
queryset = queryset.filter(required=str2bool(required))
return queryset
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
filters.SearchFilter,
]
class PartThumbs(ListAPI):
"""API endpoint for retrieving information on available Part thumbnails."""
queryset = Part.objects.all()
serializer_class = part_serializers.PartThumbSerializer
def get_queryset(self):
"""Return a queryset which exlcudes any parts without images"""
queryset = super().get_queryset()
# Get all Parts which have an associated image
queryset = queryset.exclude(image='')
return queryset
def list(self, request, *args, **kwargs):
"""Serialize the available Part images.
- Images may be used for multiple parts!
"""
queryset = self.filter_queryset(self.get_queryset())
# Return the most popular parts first
data = queryset.values(
'image',
).annotate(count=Count('image')).order_by('-count')
return Response(data)
filter_backends = [
filters.SearchFilter,
]
search_fields = [
'name',
'description',
'IPN',
'revision',
'keywords',
'category__name',
]
class PartThumbsUpdate(RetrieveUpdateAPI):
"""API endpoint for updating Part thumbnails."""
queryset = Part.objects.all()
serializer_class = part_serializers.PartThumbSerializerUpdate
filter_backends = [
DjangoFilterBackend
]
class PartScheduling(RetrieveAPI):
"""API endpoint for delivering "scheduling" information about a given part via the API.
Returns a chronologically ordered list about future "scheduled" events,
concerning stock levels for the part:
- Purchase Orders (incoming stock)
- Sales Orders (outgoing stock)
- Build Orders (incoming completed stock)
- Build Orders (outgoing allocated stock)
"""
queryset = Part.objects.all()
def retrieve(self, request, *args, **kwargs):
"""Return scheduling information for the referenced Part instance"""
today = datetime.datetime.now().date()
part = self.get_object()
schedule = []
def add_schedule_entry(date, quantity, title, label, url):
"""Check if a scheduled entry should be added:
- date must be non-null
- date cannot be in the "past"
- quantity must not be zero
"""
if date and date >= today and quantity != 0:
schedule.append({
'date': date,
'quantity': quantity,
'title': title,
'label': label,
'url': url,
})
# Add purchase order (incoming stock) information
po_lines = order.models.PurchaseOrderLineItem.objects.filter(
part__part=part,
order__status__in=PurchaseOrderStatus.OPEN,
)
for line in po_lines:
target_date = line.target_date or line.order.target_date
quantity = max(line.quantity - line.received, 0)
add_schedule_entry(
target_date,
quantity,
_('Incoming Purchase Order'),
str(line.order),
line.order.get_absolute_url()
)
# Add sales order (outgoing stock) information
so_lines = order.models.SalesOrderLineItem.objects.filter(
part=part,
order__status__in=SalesOrderStatus.OPEN,
)
for line in so_lines:
target_date = line.target_date or line.order.target_date
quantity = max(line.quantity - line.shipped, 0)
add_schedule_entry(
target_date,
-quantity,
_('Outgoing Sales Order'),
str(line.order),
line.order.get_absolute_url(),
)
# Add build orders (incoming stock) information
build_orders = Build.objects.filter(
part=part,
status__in=BuildStatus.ACTIVE_CODES
)
for build in build_orders:
quantity = max(build.quantity - build.completed, 0)
add_schedule_entry(
build.target_date,
quantity,
_('Stock produced by Build Order'),
str(build),
build.get_absolute_url(),
)
"""
Add build order allocation (outgoing stock) information.
Here we need some careful consideration:
- 'Tracked' stock items are removed from stock when the individual Build Output is completed
- 'Untracked' stock items are removed from stock when the Build Order is completed
The 'simplest' approach here is to look at existing BuildItem allocations which reference this part,
and "schedule" them for removal at the time of build order completion.
This assumes that the user is responsible for correctly allocating parts.
However, it has the added benefit of side-stepping the various BOM substition options,
and just looking at what stock items the user has actually allocated against the Build.
"""
build_allocations = BuildItem.objects.filter(
stock_item__part=part,
build__status__in=BuildStatus.ACTIVE_CODES,
)
for allocation in build_allocations:
add_schedule_entry(
allocation.build.target_date,
-allocation.quantity,
_('Stock required for Build Order'),
str(allocation.build),
allocation.build.get_absolute_url(),
)
# Sort by incrementing date values
schedule = sorted(schedule, key=lambda entry: entry['date'])
return Response(schedule)
class PartRequirements(RetrieveAPI):
"""API endpoint detailing 'requirements' information for aa particular part.
This endpoint returns information on upcoming requirements for:
- Sales Orders
- Build Orders
- Total requirements
As this data is somewhat complex to calculate, is it not included in the default API
"""
queryset = Part.objects.all()
def retrieve(self, request, *args, **kwargs):
"""Construct a response detailing Part requirements"""
part = self.get_object()
data = {
"available_stock": part.available_stock,
"on_order": part.on_order,
"required_build_order_quantity": part.required_build_order_quantity(),
"allocated_build_order_quantity": part.build_order_allocation_count(),
"required_sales_order_quantity": part.required_sales_order_quantity(),
"allocated_sales_order_quantity": part.sales_order_allocation_count(pending=True),
}
data["allocated"] = data["allocated_build_order_quantity"] + data["allocated_sales_order_quantity"]
data["required"] = data["required_build_order_quantity"] + data["required_sales_order_quantity"]
return Response(data)
class PartMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating Part metadata."""
def get_serializer(self, *args, **kwargs):
"""Returns a MetadataSerializer instance pointing to the referenced Part"""
return MetadataSerializer(Part, *args, **kwargs)
queryset = Part.objects.all()
class PartSerialNumberDetail(RetrieveAPI):
"""API endpoint for returning extra serial number information about a particular part."""
queryset = Part.objects.all()
def retrieve(self, request, *args, **kwargs):
"""Return serial number information for the referenced Part instance"""
part = self.get_object()
# Calculate the "latest" serial number
latest = part.getLatestSerialNumber()
data = {
'latest': latest,
}
if latest is not None:
next_serial = increment(latest)
if next_serial != increment:
data['next'] = next_serial
return Response(data)
class PartCopyBOM(CreateAPI):
"""API endpoint for duplicating a BOM."""
queryset = Part.objects.all()
serializer_class = part_serializers.PartCopyBOMSerializer
def get_serializer_context(self):
"""Add custom information to the serializer context for this endpoint"""
ctx = super().get_serializer_context()
try:
ctx['part'] = Part.objects.get(pk=self.kwargs.get('pk', None))
except Exception:
pass
return ctx
class PartValidateBOM(RetrieveUpdateAPI):
"""API endpoint for 'validating' the BOM for a given Part."""
class BOMValidateSerializer(serializers.ModelSerializer):
"""Simple serializer class for validating a single BomItem instance"""
class Meta:
"""Metaclass defines serializer fields"""
model = Part
fields = [
'checksum',
'valid',
]
checksum = serializers.CharField(
read_only=True,
source='bom_checksum',
)
valid = serializers.BooleanField(
write_only=True,
default=False,
label=_('Valid'),
help_text=_('Validate entire Bill of Materials'),
)
def validate_valid(self, valid):
"""Check that the 'valid' input was flagged"""
if not valid:
raise ValidationError(_('This option must be selected'))
queryset = Part.objects.all()
serializer_class = BOMValidateSerializer
def update(self, request, *args, **kwargs):
"""Validate the referenced BomItem instance"""
part = self.get_object()
partial = kwargs.pop('partial', False)
# Clean up input data before using it
data = self.clean_data(request.data)
serializer = self.get_serializer(part, data=data, partial=partial)
serializer.is_valid(raise_exception=True)
part.validate_bom(request.user)
return Response({
'checksum': part.bom_checksum,
})
class PartDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single Part object."""
queryset = Part.objects.all()
serializer_class = part_serializers.PartSerializer
starred_parts = None
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset object for the PartDetail endpoint"""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return queryset
def get_serializer(self, *args, **kwargs):
"""Return a serializer instance for the PartDetail endpoint"""
# By default, include 'category_detail' information in the detail view
try:
kwargs['category_detail'] = str2bool(self.request.query_params.get('category_detail', True))
except AttributeError:
pass
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
# Pass a list of "starred" parts of the current user to the serializer
# We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None:
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
kwargs['starred_parts'] = self.starred_parts
return self.serializer_class(*args, **kwargs)
def destroy(self, request, *args, **kwargs):
"""Delete a Part instance via the API
- If the part is 'active' it cannot be deleted
- It must first be marked as 'inactive'
"""
part = Part.objects.get(pk=int(kwargs['pk']))
# Check if inactive
if not part.active:
# Delete
return super(PartDetail, self).destroy(request, *args, **kwargs)
else:
# Return 405 error
message = 'Part is active: cannot delete'
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message)
def update(self, request, *args, **kwargs):
"""Custom update functionality for Part instance.
- If the 'starred' field is provided, update the 'starred' status against current user
"""
# Clean input data
data = self.clean_data(request.data)
if 'starred' in data:
starred = str2bool(data.get('starred', False))
self.get_object().set_starred(request.user, starred)
response = super().update(request, *args, **kwargs)
return response
class PartFilter(rest_filters.FilterSet):
"""Custom filters for the PartList endpoint.
Uses the django_filters extension framework
"""
# Filter by parts which have (or not) an IPN value
has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn')
def filter_has_ipn(self, queryset, name, value):
"""Filter by whether the Part has an IPN (internal part number) or not"""
value = str2bool(value)
if value:
queryset = queryset.exclude(IPN='')
else:
queryset = queryset.filter(IPN='')
return queryset
# Regex filter for name
name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex')
# Exact match for IPN
IPN = rest_filters.CharFilter(
label='Filter by exact IPN (internal part number)',
field_name='IPN',
lookup_expr="iexact"
)
# Regex match for IPN
IPN_regex = rest_filters.CharFilter(label='Filter by regex on IPN (internal part number)', field_name='IPN', lookup_expr='iregex')
# low_stock filter
low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock')
def filter_low_stock(self, queryset, name, value):
"""Filter by "low stock" status."""
value = str2bool(value)
if value:
# Ignore any parts which do not have a specified 'minimum_stock' level
queryset = queryset.exclude(minimum_stock=0)
# Filter items which have an 'in_stock' level lower than 'minimum_stock'
queryset = queryset.filter(Q(in_stock__lt=F('minimum_stock')))
else:
# Filter items which have an 'in_stock' level higher than 'minimum_stock'
queryset = queryset.filter(Q(in_stock__gte=F('minimum_stock')))
return queryset
# has_stock filter
has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock')
def filter_has_stock(self, queryset, name, value):
"""Filter by whether the Part has any stock"""
value = str2bool(value)
if value:
queryset = queryset.filter(Q(in_stock__gt=0))
else:
queryset = queryset.filter(Q(in_stock__lte=0))
return queryset
# unallocated_stock filter
unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock')
def filter_unallocated_stock(self, queryset, name, value):
"""Filter by whether the Part has unallocated stock"""
value = str2bool(value)
if value:
queryset = queryset.filter(Q(unallocated_stock__gt=0))
else:
queryset = queryset.filter(Q(unallocated_stock__lte=0))
return queryset
convert_from = rest_filters.ModelChoiceFilter(label="Can convert from", queryset=Part.objects.all(), method='filter_convert_from')
def filter_convert_from(self, queryset, name, part):
"""Limit the queryset to valid conversion options for the specified part"""
conversion_options = part.get_conversion_options()
queryset = queryset.filter(pk__in=conversion_options)
return queryset
exclude_tree = rest_filters.ModelChoiceFilter(label="Exclude Part tree", queryset=Part.objects.all(), method='filter_exclude_tree')
def filter_exclude_tree(self, queryset, name, part):
"""Exclude all parts and variants 'down' from the specified part from the queryset"""
children = part.get_descendants(include_self=True)
queryset = queryset.exclude(id__in=children)
return queryset
ancestor = rest_filters.ModelChoiceFilter(label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor')
def filter_ancestor(self, queryset, name, part):
"""Limit queryset to descendants of the specified ancestor part"""
descendants = part.get_descendants(include_self=False)
queryset = queryset.filter(id__in=descendants)
return queryset
variant_of = rest_filters.ModelChoiceFilter(label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of')
def filter_variant_of(self, queryset, name, part):
"""Limit queryset to direct children (variants) of the specified part"""
queryset = queryset.filter(id__in=part.get_children())
return queryset
in_bom_for = rest_filters.ModelChoiceFilter(label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom')
def filter_in_bom(self, queryset, name, part):
"""Limit queryset to parts in the BOM for the specified part"""
bom_parts = part.get_parts_in_bom()
queryset = queryset.filter(id__in=[p.pk for p in bom_parts])
return queryset
is_template = rest_filters.BooleanFilter()
assembly = rest_filters.BooleanFilter()
component = rest_filters.BooleanFilter()
trackable = rest_filters.BooleanFilter()
purchaseable = rest_filters.BooleanFilter()
salable = rest_filters.BooleanFilter()
active = rest_filters.BooleanFilter()
virtual = rest_filters.BooleanFilter()
class PartList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of Part objects.
- GET: Return list of objects
- POST: Create a new Part object
The Part object list can be filtered by:
- category: Filter by PartCategory reference
- cascade: If true, include parts from sub-categories
- starred: Is the part "starred" by the current user?
- is_template: Is the part a template part?
- variant_of: Filter by variant_of Part reference
- assembly: Filter by assembly field
- component: Filter by component field
- trackable: Filter by trackable field
- purchaseable: Filter by purcahseable field
- salable: Filter by salable field
- active: Filter by active field
- ancestor: Filter parts by 'ancestor' (template / variant tree)
"""
serializer_class = part_serializers.PartSerializer
queryset = Part.objects.all()
filterset_class = PartFilter
starred_parts = None
def get_serializer(self, *args, **kwargs):
"""Return a serializer instance for this endpoint"""
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
# Pass a list of "starred" parts fo the current user to the serializer
# We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None:
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
kwargs['starred_parts'] = self.starred_parts
try:
params = self.request.query_params
kwargs['parameters'] = str2bool(params.get('parameters', None))
except AttributeError:
pass
return self.serializer_class(*args, **kwargs)
def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file"""
dataset = PartResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Parts.{export_format}"
return DownloadFile(filedata, filename)
def list(self, request, *args, **kwargs):
"""Overide the 'list' method, as the PartCategory objects are very expensive to serialize!
So we will serialize them first, and keep them in memory, so that they do not have to be serialized multiple times...
"""
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
else:
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
# Do we wish to include PartCategory detail?
if str2bool(request.query_params.get('category_detail', False)):
# Work out which part categories we need to query
category_ids = set()
for part in data:
cat_id = part['category']
if cat_id is not None:
category_ids.add(cat_id)
# Fetch only the required PartCategory objects from the database
categories = PartCategory.objects.filter(pk__in=category_ids).prefetch_related(
'parts',
'parent',
'children',
)
category_map = {}
# Serialize each PartCategory object
for category in categories:
category_map[category.pk] = part_serializers.CategorySerializer(category).data
for part in data:
cat_id = part['category']
if cat_id is not None and cat_id in category_map.keys():
detail = category_map[cat_id]
else:
detail = None
part['category_detail'] = detail
"""
Determine the response type based on the request.
a) For HTTP requests (e.g. via the browseable API) return a DRF response
b) For AJAX requests, simply return a JSON rendered response.
"""
if page is not None:
return self.get_paginated_response(data)
elif request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
@transaction.atomic
def create(self, request, *args, **kwargs):
"""We wish to save the user who created this part!
Note: Implementation copied from DRF class CreateModelMixin
"""
# TODO: Unit tests for this function!
# Clean up input data
data = self.clean_data(request.data)
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
part = serializer.save()
part.creation_user = self.request.user
# Optionally copy templates from category or parent category
copy_templates = {
'main': str2bool(data.get('copy_category_templates', False)),
'parent': str2bool(data.get('copy_parent_templates', False))
}
part.save(**{'add_category_templates': copy_templates})
# Optionally copy data from another part (e.g. when duplicating)
copy_from = data.get('copy_from', None)
if copy_from is not None:
try:
original = Part.objects.get(pk=copy_from)
copy_bom = str2bool(data.get('copy_bom', False))
copy_parameters = str2bool(data.get('copy_parameters', False))
copy_image = str2bool(data.get('copy_image', True))
# Copy image?
if copy_image:
part.image = original.image
part.save()
# Copy BOM?
if copy_bom:
part.copy_bom_from(original)
# Copy parameter data?
if copy_parameters:
part.copy_parameters_from(original)
except (ValueError, Part.DoesNotExist):
pass
# Optionally create initial stock item
initial_stock = str2bool(data.get('initial_stock', False))
if initial_stock:
try:
initial_stock_quantity = Decimal(data.get('initial_stock_quantity', ''))
if initial_stock_quantity <= 0:
raise ValidationError({
'initial_stock_quantity': [_('Must be greater than zero')],
})
except (ValueError, InvalidOperation): # Invalid quantity provided
raise ValidationError({
'initial_stock_quantity': [_('Must be a valid quantity')],
})
initial_stock_location = data.get('initial_stock_location', None)
try:
initial_stock_location = StockLocation.objects.get(pk=initial_stock_location)
except (ValueError, StockLocation.DoesNotExist):
initial_stock_location = None
if initial_stock_location is None:
if part.default_location is not None:
initial_stock_location = part.default_location
else:
raise ValidationError({
'initial_stock_location': [_('Specify location for initial part stock')],
})
stock_item = StockItem(
part=part,
quantity=initial_stock_quantity,
location=initial_stock_location,
)
stock_item.save(user=request.user)
# Optionally add manufacturer / supplier data to the part
if part.purchaseable and str2bool(data.get('add_supplier_info', False)):
try:
manufacturer = Company.objects.get(pk=data.get('manufacturer', None))
except Exception:
manufacturer = None
try:
supplier = Company.objects.get(pk=data.get('supplier', None))
except Exception:
supplier = None
mpn = str(data.get('MPN', '')).strip()
sku = str(data.get('SKU', '')).strip()
# Construct a manufacturer part
if manufacturer or mpn:
if not manufacturer:
raise ValidationError({
'manufacturer': [_("This field is required")]
})
if not mpn:
raise ValidationError({
'MPN': [_("This field is required")]
})
manufacturer_part = ManufacturerPart.objects.create(
part=part,
manufacturer=manufacturer,
MPN=mpn
)
else:
# No manufacturer part data specified
manufacturer_part = None
if supplier or sku:
if not supplier:
raise ValidationError({
'supplier': [_("This field is required")]
})
if not sku:
raise ValidationError({
'SKU': [_("This field is required")]
})
SupplierPart.objects.create(
part=part,
supplier=supplier,
SKU=sku,
manufacturer_part=manufacturer_part,
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset object"""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return queryset
def filter_queryset(self, queryset):
"""Perform custom filtering of the queryset"""
params = self.request.query_params
queryset = super().filter_queryset(queryset)
# Exclude specific part ID values?
exclude_id = []
for key in ['exclude_id', 'exclude_id[]']:
if key in params:
exclude_id += params.getlist(key, [])
if exclude_id:
id_values = []
for val in exclude_id:
try:
# pk values must be integer castable
val = int(val)
id_values.append(val)
except ValueError:
pass
queryset = queryset.exclude(pk__in=id_values)
# Filter by whether the BOM has been validated (or not)
bom_valid = params.get('bom_valid', None)
# TODO: Querying bom_valid status may be quite expensive
# TODO: (It needs to be profiled!)
# TODO: It might be worth caching the bom_valid status to a database column
if bom_valid is not None:
bom_valid = str2bool(bom_valid)
# Limit queryset to active assemblies
queryset = queryset.filter(active=True, assembly=True)
pks = []
for part in queryset:
if part.is_bom_valid() == bom_valid:
pks.append(part.pk)
queryset = queryset.filter(pk__in=pks)
# Filter by 'related' parts?
related = params.get('related', None)
exclude_related = params.get('exclude_related', None)
if related is not None or exclude_related is not None:
try:
pk = related if related is not None else exclude_related
pk = int(pk)
related_part = Part.objects.get(pk=pk)
part_ids = set()
# Return any relationship which points to the part in question
relation_filter = Q(part_1=related_part) | Q(part_2=related_part)
for relation in PartRelated.objects.filter(relation_filter):
if relation.part_1.pk != pk:
part_ids.add(relation.part_1.pk)
if relation.part_2.pk != pk:
part_ids.add(relation.part_2.pk)
if related is not None:
# Only return related results
queryset = queryset.filter(pk__in=[pk for pk in part_ids])
elif exclude_related is not None:
# Exclude related results
queryset = queryset.exclude(pk__in=[pk for pk in part_ids])
except (ValueError, Part.DoesNotExist):
pass
# Filter by 'starred' parts?
starred = params.get('starred', None)
if starred is not None:
starred = str2bool(starred)
starred_parts = [star.part.pk for star in self.request.user.starred_parts.all()]
if starred:
queryset = queryset.filter(pk__in=starred_parts)
else:
queryset = queryset.exclude(pk__in=starred_parts)
# Cascade? (Default = True)
cascade = str2bool(params.get('cascade', True))
# Does the user wish to filter by category?
cat_id = params.get('category', None)
if cat_id is None:
# No category filtering if category is not specified
pass
else:
# Category has been specified!
if isNull(cat_id):
# A 'null' category is the top-level category
if cascade is False:
# Do not cascade, only list parts in the top-level category
queryset = queryset.filter(category=None)
else:
try:
category = PartCategory.objects.get(pk=cat_id)
# If '?cascade=true' then include parts which exist in sub-categories
if cascade:
queryset = queryset.filter(category__in=category.getUniqueChildren())
# Just return parts directly in the requested category
else:
queryset = queryset.filter(category=cat_id)
except (ValueError, PartCategory.DoesNotExist):
pass
# Filer by 'depleted_stock' status -> has no stock and stock items
depleted_stock = params.get('depleted_stock', None)
if depleted_stock is not None:
depleted_stock = str2bool(depleted_stock)
if depleted_stock:
queryset = queryset.filter(Q(in_stock=0) & ~Q(stock_item_count=0))
# Filter by "parts which need stock to complete build"
stock_to_build = params.get('stock_to_build', None)
# TODO: This is super expensive, database query wise...
# TODO: Need to figure out a cheaper way of making this filter query
if stock_to_build is not None:
# Get active builds
builds = Build.objects.filter(status__in=BuildStatus.ACTIVE_CODES)
# Store parts with builds needing stock
parts_needed_to_complete_builds = []
# Filter required parts
for build in builds:
parts_needed_to_complete_builds += [part.pk for part in build.required_parts_to_complete_build]
queryset = queryset.filter(pk__in=parts_needed_to_complete_builds)
# Optionally limit the maximum number of returned results
# e.g. for displaying "recent part" list
max_results = params.get('max_results', None)
if max_results is not None:
try:
max_results = int(max_results)
if max_results > 0:
queryset = queryset[:max_results]
except (ValueError):
pass
return queryset
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
ordering_fields = [
'name',
'creation_date',
'IPN',
'in_stock',
'unallocated_stock',
'category',
]
# Default ordering
ordering = 'name'
search_fields = [
'name',
'description',
'IPN',
'revision',
'keywords',
'category__name',
'manufacturer_parts__MPN',
'supplier_parts__SKU',
]
class PartRelatedList(ListCreateAPI):
"""API endpoint for accessing a list of PartRelated objects."""
queryset = PartRelated.objects.all()
serializer_class = part_serializers.PartRelationSerializer
def filter_queryset(self, queryset):
"""Custom queryset filtering"""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Add a filter for "part" - we can filter either part_1 or part_2
part = params.get('part', None)
if part is not None:
try:
part = Part.objects.get(pk=part)
queryset = queryset.filter(Q(part_1=part) | Q(part_2=part))
except (ValueError, Part.DoesNotExist):
pass
return queryset
class PartRelatedDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for accessing detail view of a PartRelated object."""
queryset = PartRelated.objects.all()
serializer_class = part_serializers.PartRelationSerializer
class PartParameterTemplateList(ListCreateAPI):
"""API endpoint for accessing a list of PartParameterTemplate objects.
- GET: Return list of PartParameterTemplate objects
- POST: Create a new PartParameterTemplate object
"""
queryset = PartParameterTemplate.objects.all()
serializer_class = part_serializers.PartParameterTemplateSerializer
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
filters.SearchFilter,
]
filterset_fields = [
'name',
]
search_fields = [
'name',
]
def filter_queryset(self, queryset):
"""Custom filtering for the PartParameterTemplate API."""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Filtering against a "Part" - return only parameter templates which are referenced by a part
part = params.get('part', None)
if part is not None:
try:
part = Part.objects.get(pk=part)
parameters = PartParameter.objects.filter(part=part)
template_ids = parameters.values_list('template').distinct()
queryset = queryset.filter(pk__in=[el[0] for el in template_ids])
except (ValueError, Part.DoesNotExist):
pass
# Filtering against a "PartCategory" - return only parameter templates which are referenced by parts in this category
category = params.get('category', None)
if category is not None:
try:
category = PartCategory.objects.get(pk=category)
cats = category.get_descendants(include_self=True)
parameters = PartParameter.objects.filter(part__category__in=cats)
template_ids = parameters.values_list('template').distinct()
queryset = queryset.filter(pk__in=[el[0] for el in template_ids])
except (ValueError, PartCategory.DoesNotExist):
pass
return queryset
class PartParameterTemplateDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for accessing the detail view for a PartParameterTemplate object"""
queryset = PartParameterTemplate.objects.all()
serializer_class = part_serializers.PartParameterTemplateSerializer
class PartParameterList(ListCreateAPI):
"""API endpoint for accessing a list of PartParameter objects.
- GET: Return list of PartParameter objects
- POST: Create a new PartParameter object
"""
queryset = PartParameter.objects.all()
serializer_class = part_serializers.PartParameterSerializer
filter_backends = [
DjangoFilterBackend
]
filterset_fields = [
'part',
'template',
]
class PartParameterDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single PartParameter object."""
queryset = PartParameter.objects.all()
serializer_class = part_serializers.PartParameterSerializer
class BomFilter(rest_filters.FilterSet):
"""Custom filters for the BOM list."""
# Boolean filters for BOM item
optional = rest_filters.BooleanFilter(label='BOM line is optional')
inherited = rest_filters.BooleanFilter(label='BOM line is inherited')
allow_variants = rest_filters.BooleanFilter(label='Variants are allowed')
# Filters for linked 'part'
part_active = rest_filters.BooleanFilter(label='Master part is active', field_name='part__active')
part_trackable = rest_filters.BooleanFilter(label='Master part is trackable', field_name='part__trackable')
# Filters for linked 'sub_part'
sub_part_trackable = rest_filters.BooleanFilter(label='Sub part is trackable', field_name='sub_part__trackable')
sub_part_assembly = rest_filters.BooleanFilter(label='Sub part is an assembly', field_name='sub_part__assembly')
validated = rest_filters.BooleanFilter(label='BOM line has been validated', method='filter_validated')
def filter_validated(self, queryset, name, value):
"""Filter by which lines have actually been validated"""
pks = []
value = str2bool(value)
# Shortcut for quicker filtering - BomItem with empty 'checksum' values are not validated
if value:
queryset = queryset.exclude(checksum=None).exclude(checksum='')
for bom_item in queryset.all():
if bom_item.is_line_valid:
pks.append(bom_item.pk)
if value:
queryset = queryset.filter(pk__in=pks)
else:
queryset = queryset.exclude(pk__in=pks)
return queryset
available_stock = rest_filters.BooleanFilter(label="Has available stock", method="filter_available_stock")
def filter_available_stock(self, queryset, name, value):
"""Filter the queryset based on whether each line item has any available stock"""
value = str2bool(value)
if value:
queryset = queryset.filter(available_stock__gt=0)
else:
queryset = queryset.filter(available_stock=0)
return queryset
on_order = rest_filters.BooleanFilter(label="On order", method="filter_on_order")
def filter_on_order(self, queryset, name, value):
"""Filter the queryset based on whether each line item has any stock on order"""
value = str2bool(value)
if value:
queryset = queryset.filter(on_order__gt=0)
else:
queryset = queryset.filter(on_order=0)
return queryset
class BomList(ListCreateDestroyAPIView):
"""API endpoint for accessing a list of BomItem objects.
- GET: Return list of BomItem objects
- POST: Create a new BomItem object
"""
serializer_class = part_serializers.BomItemSerializer
queryset = BomItem.objects.all()
filterset_class = BomFilter
def list(self, request, *args, **kwargs):
"""Return serialized list response for this endpoint"""
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
else:
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
"""
Determine the response type based on the request.
a) For HTTP requests (e.g. via the browseable API) return a DRF response
b) For AJAX requests, simply return a JSON rendered response.
"""
if page is not None:
return self.get_paginated_response(data)
elif request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this API endpoint
If requested, extra detail fields are annotated to the queryset:
- part_detail
- sub_part_detail
- include_pricing
"""
# Do we wish to include extra detail?
try:
kwargs['part_detail'] = str2bool(self.request.GET.get('part_detail', None))
except AttributeError:
pass
try:
kwargs['sub_part_detail'] = str2bool(self.request.GET.get('sub_part_detail', None))
except AttributeError:
pass
try:
# Include or exclude pricing information in the serialized data
kwargs['include_pricing'] = self.include_pricing()
except AttributeError:
pass
# Ensure the request context is passed through!
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return the queryset object for this endpoint"""
queryset = super().get_queryset(*args, **kwargs)
queryset = self.get_serializer_class().setup_eager_loading(queryset)
queryset = self.get_serializer_class().annotate_queryset(queryset)
return queryset
def filter_queryset(self, queryset):
"""Custom query filtering for the BomItem list API"""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Filter by part?
part = params.get('part', None)
if part is not None:
"""
If we are filtering by "part", there are two cases to consider:
a) Bom items which are defined for *this* part
b) Inherited parts which are defined for a *parent* part
So we need to construct two queries!
"""
# First, check that the part is actually valid!
try:
part = Part.objects.get(pk=part)
queryset = queryset.filter(part.get_bom_item_filter())
except (ValueError, Part.DoesNotExist):
pass
"""
Filter by 'uses'?
Here we pass a part ID and return BOM items for any assemblies which "use" (or "require") that part.
There are multiple ways that an assembly can "use" a sub-part:
A) Directly specifying the sub_part in a BomItem field
B) Specifing a "template" part with inherited=True
C) Allowing variant parts to be substituted
D) Allowing direct substitute parts to be specified
- BOM items which are "inherited" by parts which are variants of the master BomItem
"""
uses = params.get('uses', None)
if uses is not None:
try:
# Extract the part we are interested in
uses_part = Part.objects.get(pk=uses)
# Construct the database query in multiple parts
# A) Direct specification of sub_part
q_A = Q(sub_part=uses_part)
# B) BomItem is inherited and points to a "parent" of this part
parents = uses_part.get_ancestors(include_self=False)
q_B = Q(
inherited=True,
sub_part__in=parents
)
# C) Substitution of variant parts
# TODO
# D) Specification of individual substitutes
# TODO
q = q_A | q_B
queryset = queryset.filter(q)
except (ValueError, Part.DoesNotExist):
pass
if self.include_pricing():
queryset = self.annotate_pricing(queryset)
return queryset
def include_pricing(self):
"""Determine if pricing information should be included in the response."""
pricing_default = InvenTreeSetting.get_setting('PART_SHOW_PRICE_IN_BOM')
return str2bool(self.request.query_params.get('include_pricing', pricing_default))
def annotate_pricing(self, queryset):
"""Add part pricing information to the queryset."""
# Annotate with purchase prices
queryset = queryset.annotate(
purchase_price_min=Min('sub_part__stock_items__purchase_price'),
purchase_price_max=Max('sub_part__stock_items__purchase_price'),
purchase_price_avg=Avg('sub_part__stock_items__purchase_price'),
)
# Get values for currencies
currencies = queryset.annotate(
purchase_price=F('sub_part__stock_items__purchase_price'),
purchase_price_currency=F('sub_part__stock_items__purchase_price_currency'),
).values('pk', 'sub_part', 'purchase_price', 'purchase_price_currency')
def convert_price(price, currency, decimal_places=4):
"""Convert price field, returns Money field."""
price_adjusted = None
# Get default currency from settings
default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
if price:
if currency and default_currency:
try:
# Get adjusted price
price_adjusted = convert_money(Money(price, currency), default_currency)
except MissingRate:
# No conversion rate set
price_adjusted = Money(price, currency)
else:
# Currency exists
if currency:
price_adjusted = Money(price, currency)
# Default currency exists
if default_currency:
price_adjusted = Money(price, default_currency)
if price_adjusted and decimal_places:
price_adjusted.decimal_places = decimal_places
return price_adjusted
# Convert prices to default currency (using backend conversion rates)
for bom_item in queryset:
# Find associated currency (select first found)
purchase_price_currency = None
for currency_item in currencies:
if currency_item['pk'] == bom_item.pk and currency_item['sub_part'] == bom_item.sub_part.pk and currency_item['purchase_price']:
purchase_price_currency = currency_item['purchase_price_currency']
break
# Convert prices
bom_item.purchase_price_min = convert_price(bom_item.purchase_price_min, purchase_price_currency)
bom_item.purchase_price_max = convert_price(bom_item.purchase_price_max, purchase_price_currency)
bom_item.purchase_price_avg = convert_price(bom_item.purchase_price_avg, purchase_price_currency)
return queryset
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
InvenTreeOrderingFilter,
]
filterset_fields = [
]
search_fields = [
'reference',
'sub_part__name',
'sub_part__description',
'sub_part__IPN',
'sub_part__revision',
'sub_part__keywords',
'sub_part__category__name',
]
ordering_fields = [
'quantity',
'sub_part',
'available_stock',
]
ordering_field_aliases = {
'sub_part': 'sub_part__name',
}
class BomImportUpload(CreateAPI):
"""API endpoint for uploading a complete Bill of Materials.
It is assumed that the BOM has been extracted from a file using the BomExtract endpoint.
"""
queryset = Part.objects.all()
serializer_class = part_serializers.BomImportUploadSerializer
def create(self, request, *args, **kwargs):
"""Custom create function to return the extracted data."""
# Clean up input data
data = self.clean_data(request.data)
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
data = serializer.extract_data()
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
class BomImportExtract(CreateAPI):
"""API endpoint for extracting BOM data from a BOM file."""
queryset = Part.objects.none()
serializer_class = part_serializers.BomImportExtractSerializer
class BomImportSubmit(CreateAPI):
"""API endpoint for submitting BOM data from a BOM file."""
queryset = BomItem.objects.none()
serializer_class = part_serializers.BomImportSubmitSerializer
class BomDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single BomItem object."""
queryset = BomItem.objects.all()
serializer_class = part_serializers.BomItemSerializer
def get_queryset(self, *args, **kwargs):
"""Prefetch related fields for this queryset"""
queryset = super().get_queryset(*args, **kwargs)
queryset = self.get_serializer_class().setup_eager_loading(queryset)
queryset = self.get_serializer_class().annotate_queryset(queryset)
return queryset
class BomItemValidate(UpdateAPI):
"""API endpoint for validating a BomItem."""
class BomItemValidationSerializer(serializers.Serializer):
"""Simple serializer for passing a single boolean field"""
valid = serializers.BooleanField(default=False)
queryset = BomItem.objects.all()
serializer_class = BomItemValidationSerializer
def update(self, request, *args, **kwargs):
"""Perform update request."""
partial = kwargs.pop('partial', False)
# Clean up input data
data = self.clean_data(request.data)
valid = data.get('valid', False)
instance = self.get_object()
serializer = self.get_serializer(instance, data=data, partial=partial)
serializer.is_valid(raise_exception=True)
if type(instance) == BomItem:
instance.validate_hash(valid)
return Response(serializer.data)
class BomItemSubstituteList(ListCreateAPI):
"""API endpoint for accessing a list of BomItemSubstitute objects."""
serializer_class = part_serializers.BomItemSubstituteSerializer
queryset = BomItemSubstitute.objects.all()
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = [
'part',
'bom_item',
]
class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single BomItemSubstitute object."""
queryset = BomItemSubstitute.objects.all()
serializer_class = part_serializers.BomItemSubstituteSerializer
part_api_urls = [
# Base URL for PartCategory API endpoints
re_path(r'^category/', include([
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
re_path(r'^parameters/', include([
re_path('^(?P<pk>\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'),
re_path('^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
])),
# Category detail endpoints
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'),
# PartCategory detail endpoint
re_path(r'^.*$', CategoryDetail.as_view(), name='api-part-category-detail'),
])),
path('', CategoryList.as_view(), name='api-part-category-list'),
])),
# Base URL for PartTestTemplate API endpoints
re_path(r'^test-template/', include([
re_path(r'^(?P<pk>\d+)/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'),
path('', PartTestTemplateList.as_view(), name='api-part-test-template-list'),
])),
# Base URL for PartAttachment API endpoints
re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', PartAttachmentDetail.as_view(), name='api-part-attachment-detail'),
path('', PartAttachmentList.as_view(), name='api-part-attachment-list'),
])),
# Base URL for part sale pricing
re_path(r'^sale-price/', include([
re_path(r'^(?P<pk>\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
re_path(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
])),
# Base URL for part internal pricing
re_path(r'^internal-price/', include([
re_path(r'^(?P<pk>\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
re_path(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])),
# Base URL for PartRelated API endpoints
re_path(r'^related/', include([
re_path(r'^(?P<pk>\d+)/', PartRelatedDetail.as_view(), name='api-part-related-detail'),
re_path(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'),
])),
# Base URL for PartParameter API endpoints
re_path(r'^parameter/', include([
path('template/', include([
re_path(r'^(?P<pk>\d+)/', PartParameterTemplateDetail.as_view(), name='api-part-parameter-template-detail'),
re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
])),
re_path(r'^(?P<pk>\d+)/', PartParameterDetail.as_view(), name='api-part-parameter-detail'),
re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'),
])),
re_path(r'^thumbs/', include([
path('', PartThumbs.as_view(), name='api-part-thumbs'),
re_path(r'^(?P<pk>\d+)/?', PartThumbsUpdate.as_view(), name='api-part-thumbs-update'),
])),
re_path(r'^(?P<pk>\d+)/', include([
# Endpoint for extra serial number information
re_path(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'),
# Endpoint for future scheduling information
re_path(r'^scheduling/', PartScheduling.as_view(), name='api-part-scheduling'),
re_path(r'^requirements/', PartRequirements.as_view(), name='api-part-requirements'),
# Endpoint for duplicating a BOM for the specific Part
re_path(r'^bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'),
# Endpoint for validating a BOM for the specific Part
re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
# Part metadata
re_path(r'^metadata/', PartMetadata.as_view(), name='api-part-metadata'),
# Part detail endpoint
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
])),
re_path(r'^.*$', PartList.as_view(), name='api-part-list'),
]
bom_api_urls = [
re_path(r'^substitute/', include([
# Detail view
re_path(r'^(?P<pk>\d+)/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'),
# Catch all
re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),
])),
# BOM Item Detail
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),
re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
])),
# API endpoint URLs for importing BOM data
re_path(r'^import/upload/', BomImportUpload.as_view(), name='api-bom-import-upload'),
re_path(r'^import/extract/', BomImportExtract.as_view(), name='api-bom-import-extract'),
re_path(r'^import/submit/', BomImportSubmit.as_view(), name='api-bom-import-submit'),
# Catch-all
re_path(r'^.*$', BomList.as_view(), name='api-bom-list'),
]