mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-03 15:52:51 +00:00
Refactor API endpoint: Part (3/6) (#10441)
* refactor(api): cascade and category filters in PartList * Refactor exclude and relate parts in PartFilter * add output options for Part and BOM endpoints * add NumericInFilter for excluding parts by ID and related parts * refactor: clean up unused code and improve output options in API * Bump API version to 395 and update changelog with recent endpoint refactors * Fix formatting of API version history in api_version.py * Update API version history dates in api_version.py * Enhance NumericInFilter tests: add handling for non-existent IDs, empty strings, and invalid inputs
This commit is contained in:
@@ -1,21 +1,27 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 394
|
INVENTREE_API_VERSION = 395
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
v394 -> 2025-09-01 : https://github.com/inventree/InvenTree/pull/10438
|
v395 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10441
|
||||||
- Refactors 'bom_item_detail', 'assembly_detail', 'part_detail', 'build_detail' and 'allocations' param in user BuildLine API endpoint
|
- Refactors 'parameters', 'category_detail', 'location_detail' and 'path_detail' params in Part API endpoint
|
||||||
- Refactors 'part_detail', 'location_detail', 'stock_detail' and 'build_detail' param in user BuildItem API endpoint
|
- Refactors 'can_build', 'part_detail', 'sub_part_detail' and 'path_detail' params in BOM API endpoint
|
||||||
|
- Refactors 'path_detail' params in user Category API endpoint
|
||||||
|
- Refactors 'exclude_id', 'related' and 'exclude_related' params in Part API endpoint
|
||||||
|
|
||||||
v393 -> 2025-09-01 : https://github.com/inventree/InvenTree/pull/10437
|
v394 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10438
|
||||||
- Refactors 'user_detail', 'permission_detail', 'role_detail' param in user GroupList API endpoint
|
- Refactors 'bom_item_detail', 'assembly_detail', 'part_detail', 'build_detail' and 'allocations' params in BuildLine API endpoint
|
||||||
|
- Refactors 'part_detail', 'location_detail', 'stock_detail' and 'build_detail' params in BuildItem API endpoint
|
||||||
|
|
||||||
|
v393 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10437
|
||||||
|
- Refactors 'user_detail', 'permission_detail', 'role_detail' params in user GroupList API endpoint
|
||||||
|
|
||||||
v392 -> 2025-09-22 : https://github.com/inventree/InvenTree/pull/10374
|
v392 -> 2025-09-22 : https://github.com/inventree/InvenTree/pull/10374
|
||||||
- Refactors 'part_detail', 'supplier_detail', 'manufacturer_detail'and 'pretty' param in SupplierPartList API endpoint
|
- Refactors 'part_detail', 'supplier_detail', 'manufacturer_detail'and 'pretty' params in SupplierPartList API endpoint
|
||||||
|
|
||||||
v391 -> 2025-09-06 : https://github.com/inventree/InvenTree/pull/10279
|
v391 -> 2025-09-06 : https://github.com/inventree/InvenTree/pull/10279
|
||||||
- Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint
|
- Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import make_aware
|
from django.utils.timezone import make_aware
|
||||||
|
|
||||||
@@ -192,6 +193,33 @@ class NumberOrNullFilter(rest_filters.NumberFilter):
|
|||||||
return field
|
return field
|
||||||
|
|
||||||
|
|
||||||
|
class NumericInFilter(rest_filters.BaseInFilter):
|
||||||
|
"""A filter that only accepts numeric values for 'in' queries.
|
||||||
|
|
||||||
|
This filter ensures that all provided values can be converted to integers
|
||||||
|
before passing them to the parent filter. Any non-numeric values will
|
||||||
|
be ignored (or optionally, a ValidationError can be raised).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter(self, qs, value):
|
||||||
|
"""Filter the queryset based on numeric values only."""
|
||||||
|
if not value:
|
||||||
|
return qs
|
||||||
|
|
||||||
|
# Check that all values are numeric
|
||||||
|
numeric_values = []
|
||||||
|
for v in value:
|
||||||
|
try:
|
||||||
|
numeric_values.append(int(v))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise ValidationError(f"'{v}' is not a valid number")
|
||||||
|
|
||||||
|
if not numeric_values:
|
||||||
|
return qs
|
||||||
|
|
||||||
|
return super().filter(qs, numeric_values)
|
||||||
|
|
||||||
|
|
||||||
SEARCH_ORDER_FILTER = [
|
SEARCH_ORDER_FILTER = [
|
||||||
drf_backend.DjangoFilterBackend,
|
drf_backend.DjangoFilterBackend,
|
||||||
InvenTreeSearchFilter,
|
InvenTreeSearchFilter,
|
||||||
|
@@ -22,6 +22,7 @@ from InvenTree.api import (
|
|||||||
ListCreateDestroyAPIView,
|
ListCreateDestroyAPIView,
|
||||||
MetadataView,
|
MetadataView,
|
||||||
)
|
)
|
||||||
|
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||||
from InvenTree.filters import (
|
from InvenTree.filters import (
|
||||||
ORDER_FILTER,
|
ORDER_FILTER,
|
||||||
ORDER_FILTER_ALIAS,
|
ORDER_FILTER_ALIAS,
|
||||||
@@ -29,13 +30,16 @@ from InvenTree.filters import (
|
|||||||
SEARCH_ORDER_FILTER_ALIAS,
|
SEARCH_ORDER_FILTER_ALIAS,
|
||||||
InvenTreeDateFilter,
|
InvenTreeDateFilter,
|
||||||
InvenTreeSearchFilter,
|
InvenTreeSearchFilter,
|
||||||
|
NumberOrNullFilter,
|
||||||
|
NumericInFilter,
|
||||||
)
|
)
|
||||||
from InvenTree.helpers import isNull, str2bool
|
from InvenTree.helpers import str2bool
|
||||||
from InvenTree.mixins import (
|
from InvenTree.mixins import (
|
||||||
CreateAPI,
|
CreateAPI,
|
||||||
CustomRetrieveUpdateDestroyAPI,
|
CustomRetrieveUpdateDestroyAPI,
|
||||||
ListAPI,
|
ListAPI,
|
||||||
ListCreateAPI,
|
ListCreateAPI,
|
||||||
|
OutputOptionsMixin,
|
||||||
RetrieveAPI,
|
RetrieveAPI,
|
||||||
RetrieveUpdateAPI,
|
RetrieveUpdateAPI,
|
||||||
RetrieveUpdateDestroyAPI,
|
RetrieveUpdateDestroyAPI,
|
||||||
@@ -67,17 +71,6 @@ class CategoryMixin:
|
|||||||
serializer_class = part_serializers.CategorySerializer
|
serializer_class = part_serializers.CategorySerializer
|
||||||
queryset = PartCategory.objects.all()
|
queryset = PartCategory.objects.all()
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
|
||||||
"""Add additional context based on query parameters."""
|
|
||||||
try:
|
|
||||||
params = self.request.query_params
|
|
||||||
|
|
||||||
kwargs['path_detail'] = str2bool(params.get('path_detail', False))
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return super().get_serializer(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return an annotated queryset for the CategoryDetail endpoint."""
|
"""Return an annotated queryset for the CategoryDetail endpoint."""
|
||||||
queryset = super().get_queryset(*args, **kwargs)
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
@@ -235,7 +228,19 @@ class CategoryFilter(FilterSet):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
|
class CategoryOutputOption(OutputConfiguration):
|
||||||
|
"""Output option for PartCategory endpoints."""
|
||||||
|
|
||||||
|
OPTIONS = [InvenTreeOutputOption(flag='path_detail')]
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryList(
|
||||||
|
CategoryMixin,
|
||||||
|
BulkUpdateMixin,
|
||||||
|
DataExportViewMixin,
|
||||||
|
OutputOptionsMixin,
|
||||||
|
ListCreateAPI,
|
||||||
|
):
|
||||||
"""API endpoint for accessing a list of PartCategory objects.
|
"""API endpoint for accessing a list of PartCategory objects.
|
||||||
|
|
||||||
- GET: Return a list of PartCategory objects
|
- GET: Return a list of PartCategory objects
|
||||||
@@ -246,6 +251,8 @@ class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCrea
|
|||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
|
||||||
|
output_options = CategoryOutputOption
|
||||||
|
|
||||||
ordering_fields = ['name', 'pathstring', 'level', 'tree_id', 'lft', 'part_count']
|
ordering_fields = ['name', 'pathstring', 'level', 'tree_id', 'lft', 'part_count']
|
||||||
|
|
||||||
# Use hierarchical ordering by default
|
# Use hierarchical ordering by default
|
||||||
@@ -254,9 +261,11 @@ class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCrea
|
|||||||
search_fields = ['name', 'description', 'pathstring']
|
search_fields = ['name', 'description', 'pathstring']
|
||||||
|
|
||||||
|
|
||||||
class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
|
class CategoryDetail(CategoryMixin, OutputOptionsMixin, CustomRetrieveUpdateDestroyAPI):
|
||||||
"""API endpoint for detail view of a single PartCategory object."""
|
"""API endpoint for detail view of a single PartCategory object."""
|
||||||
|
|
||||||
|
output_options = CategoryOutputOption
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
"""Perform 'update' function and mark this part as 'starred' (or not)."""
|
"""Perform 'update' function and mark this part as 'starred' (or not)."""
|
||||||
# Clean up input data
|
# Clean up input data
|
||||||
@@ -909,6 +918,101 @@ class PartFilter(FilterSet):
|
|||||||
label='Updated after', field_name='creation_date', lookup_expr='gt'
|
label='Updated after', field_name='creation_date', lookup_expr='gt'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
exclude_id = NumericInFilter(
|
||||||
|
field_name='id',
|
||||||
|
lookup_expr='in',
|
||||||
|
exclude=True,
|
||||||
|
help_text='Exclude parts with these IDs (comma-separated)',
|
||||||
|
)
|
||||||
|
|
||||||
|
related = rest_filters.NumberFilter(
|
||||||
|
method='filter_related_parts', help_text='Show parts related to this part ID'
|
||||||
|
)
|
||||||
|
|
||||||
|
exclude_related = rest_filters.NumberFilter(
|
||||||
|
method='filter_exclude_related_parts',
|
||||||
|
help_text='Exclude parts related to this part ID',
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_related_parts(self, queryset, name, value):
|
||||||
|
"""Filter parts related to the specified part ID."""
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
try:
|
||||||
|
related_part = Part.objects.get(pk=value)
|
||||||
|
part_ids = self._get_related_part_ids(related_part)
|
||||||
|
return queryset.filter(pk__in=list(part_ids))
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
def filter_exclude_related_parts(self, queryset, name, value):
|
||||||
|
"""Exclude parts related to the specified part ID."""
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
try:
|
||||||
|
related_part = Part.objects.get(pk=value)
|
||||||
|
part_ids = self._get_related_part_ids(related_part)
|
||||||
|
return queryset.exclude(pk__in=list(part_ids))
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def _get_related_part_ids(self, related_part):
|
||||||
|
"""Return a set of part IDs which are related to the specified part."""
|
||||||
|
part_ids = set()
|
||||||
|
pk = related_part.pk
|
||||||
|
|
||||||
|
relation_filter = Q(part_1=related_part) | Q(part_2=related_part)
|
||||||
|
|
||||||
|
for relation in PartRelated.objects.filter(relation_filter).distinct():
|
||||||
|
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)
|
||||||
|
|
||||||
|
return part_ids
|
||||||
|
|
||||||
|
cascade = rest_filters.BooleanFilter(
|
||||||
|
method='filter_cascade',
|
||||||
|
label=_('Cascade Categories'),
|
||||||
|
help_text=_('If true, include items in child categories of the given category'),
|
||||||
|
)
|
||||||
|
|
||||||
|
category = NumberOrNullFilter(
|
||||||
|
method='filter_category',
|
||||||
|
label=_('Category'),
|
||||||
|
help_text=_("Filter by numeric category ID or the literal 'null'"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_cascade(self, queryset, name, value):
|
||||||
|
"""Dummy filter method for 'cascade'.
|
||||||
|
|
||||||
|
- Ensures 'cascade' appears in API documentation
|
||||||
|
- Does NOT actually filter the queryset directly
|
||||||
|
"""
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def filter_category(self, queryset, name, value):
|
||||||
|
"""Filter for category that also applies cascade logic."""
|
||||||
|
cascade = str2bool(self.data.get('cascade', True))
|
||||||
|
|
||||||
|
if value == 'null':
|
||||||
|
if not cascade:
|
||||||
|
return queryset.filter(category=None)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
if not cascade:
|
||||||
|
return queryset.filter(category=value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
category = PartCategory.objects.get(pk=value)
|
||||||
|
except PartCategory.DoesNotExist:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
children = category.getUniqueChildren()
|
||||||
|
return queryset.filter(category__in=children)
|
||||||
|
|
||||||
|
|
||||||
class PartMixin:
|
class PartMixin:
|
||||||
"""Mixin class for Part API endpoints."""
|
"""Mixin class for Part API endpoints."""
|
||||||
@@ -952,17 +1056,6 @@ class PartMixin:
|
|||||||
|
|
||||||
kwargs['starred_parts'] = self.starred_parts
|
kwargs['starred_parts'] = self.starred_parts
|
||||||
|
|
||||||
try:
|
|
||||||
params = self.request.query_params
|
|
||||||
|
|
||||||
kwargs['parameters'] = str2bool(params.get('parameters', None))
|
|
||||||
kwargs['category_detail'] = str2bool(params.get('category_detail', False))
|
|
||||||
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
|
|
||||||
kwargs['path_detail'] = str2bool(params.get('path_detail', False))
|
|
||||||
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return super().get_serializer(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
@@ -973,100 +1066,32 @@ class PartMixin:
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
|
class PartOutputOptions(OutputConfiguration):
|
||||||
|
"""Output options for Part endpoints."""
|
||||||
|
|
||||||
|
OPTIONS = [
|
||||||
|
InvenTreeOutputOption(
|
||||||
|
'parameters', description='Include part parameters in response'
|
||||||
|
),
|
||||||
|
InvenTreeOutputOption('category_detail'),
|
||||||
|
InvenTreeOutputOption('location_detail'),
|
||||||
|
InvenTreeOutputOption('path_detail'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PartList(
|
||||||
|
PartMixin, BulkUpdateMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
|
||||||
|
):
|
||||||
"""API endpoint for accessing a list of Part objects, or creating a new Part instance."""
|
"""API endpoint for accessing a list of Part objects, or creating a new Part instance."""
|
||||||
|
|
||||||
|
output_options = PartOutputOptions
|
||||||
filterset_class = PartFilter
|
filterset_class = PartFilter
|
||||||
is_create = True
|
is_create = True
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""Perform custom filtering of the queryset."""
|
"""Perform custom filtering of the queryset."""
|
||||||
params = self.request.query_params
|
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
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 '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).distinct():
|
|
||||||
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=list(part_ids))
|
|
||||||
elif exclude_related is not None:
|
|
||||||
# Exclude related results
|
|
||||||
queryset = queryset.exclude(pk__in=list(part_ids))
|
|
||||||
|
|
||||||
except (ValueError, Part.DoesNotExist):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 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 not None:
|
|
||||||
# Category has been specified!
|
|
||||||
if isNull(cat_id):
|
|
||||||
# A 'null' category is the top-level category
|
|
||||||
if not cascade:
|
|
||||||
# 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
|
|
||||||
|
|
||||||
queryset = self.filter_parametric_data(queryset)
|
queryset = self.filter_parametric_data(queryset)
|
||||||
queryset = self.order_by_parameter(queryset)
|
queryset = self.order_by_parameter(queryset)
|
||||||
|
|
||||||
@@ -1174,9 +1199,11 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartDetail(PartMixin, RetrieveUpdateDestroyAPI):
|
class PartDetail(PartMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
|
||||||
"""API endpoint for detail view of a single Part object."""
|
"""API endpoint for detail view of a single Part object."""
|
||||||
|
|
||||||
|
output_options = PartOutputOptions
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
"""Custom update functionality for Part instance.
|
"""Custom update functionality for Part instance.
|
||||||
|
|
||||||
@@ -1594,23 +1621,7 @@ class BomMixin:
|
|||||||
queryset = BomItem.objects.all()
|
queryset = BomItem.objects.all()
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
"""Return the serializer instance for this API endpoint.
|
"""Return the serializer instance for this API endpoint."""
|
||||||
|
|
||||||
If requested, extra detail fields are annotated to the queryset:
|
|
||||||
- part_detail
|
|
||||||
- sub_part_detail
|
|
||||||
"""
|
|
||||||
# Do we wish to include extra detail?
|
|
||||||
try:
|
|
||||||
params = self.request.query_params
|
|
||||||
|
|
||||||
kwargs['can_build'] = str2bool(params.get('can_build', True))
|
|
||||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
|
||||||
kwargs['sub_part_detail'] = str2bool(params.get('sub_part_detail', False))
|
|
||||||
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Ensure the request context is passed through!
|
# Ensure the request context is passed through!
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
@@ -1625,13 +1636,26 @@ class BomMixin:
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class BomList(BomMixin, DataExportViewMixin, ListCreateDestroyAPIView):
|
class BomOutputOptions(OutputConfiguration):
|
||||||
|
"""Output options for BOM endpoints."""
|
||||||
|
|
||||||
|
OPTIONS = [
|
||||||
|
InvenTreeOutputOption('can_build', default=True),
|
||||||
|
InvenTreeOutputOption('part_detail'),
|
||||||
|
InvenTreeOutputOption('sub_part_detail'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BomList(
|
||||||
|
BomMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateDestroyAPIView
|
||||||
|
):
|
||||||
"""API endpoint for accessing a list of BomItem objects.
|
"""API endpoint for accessing a list of BomItem objects.
|
||||||
|
|
||||||
- GET: Return list of BomItem objects
|
- GET: Return list of BomItem objects
|
||||||
- POST: Create a new BomItem object
|
- POST: Create a new BomItem object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
output_options = BomOutputOptions
|
||||||
filterset_class = BomFilter
|
filterset_class = BomFilter
|
||||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||||
|
|
||||||
@@ -1680,9 +1704,11 @@ class BomList(BomMixin, DataExportViewMixin, ListCreateDestroyAPIView):
|
|||||||
bom_item.check_part_lock(bom_item.part)
|
bom_item.check_part_lock(bom_item.part)
|
||||||
|
|
||||||
|
|
||||||
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI):
|
class BomDetail(BomMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
|
||||||
"""API endpoint for detail view of a single BomItem object."""
|
"""API endpoint for detail view of a single BomItem object."""
|
||||||
|
|
||||||
|
output_options = BomOutputOptions
|
||||||
|
|
||||||
|
|
||||||
class BomItemValidate(UpdateAPI):
|
class BomItemValidate(UpdateAPI):
|
||||||
"""API endpoint for validating a BomItem."""
|
"""API endpoint for validating a BomItem."""
|
||||||
|
@@ -906,6 +906,82 @@ class PartAPITest(PartAPITestBase):
|
|||||||
response = self.get(url, {'related': 1}, expected_code=200)
|
response = self.get(url, {'related': 1}, expected_code=200)
|
||||||
self.assertEqual(len(response.data), 2)
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
|
def test_exclude_related(self):
|
||||||
|
"""Test that we can exclude parts related to a specific part ID."""
|
||||||
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
|
# Get initial count of all parts
|
||||||
|
response = self.get(url, {}, expected_code=200)
|
||||||
|
initial_count = len(response.data)
|
||||||
|
|
||||||
|
# Add some relationships
|
||||||
|
PartRelated.objects.create(
|
||||||
|
part_1=Part.objects.get(pk=1), part_2=Part.objects.get(pk=2)
|
||||||
|
)
|
||||||
|
|
||||||
|
PartRelated.objects.create(
|
||||||
|
part_2=Part.objects.get(pk=1), part_1=Part.objects.get(pk=3)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test excluding parts related to part 1
|
||||||
|
# Parts 2 and 3 are related to part 1, so they should be excluded
|
||||||
|
response = self.get(url, {'exclude_related': 1}, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), initial_count - 2)
|
||||||
|
|
||||||
|
# Verify that parts 2 and 3 are not in the results
|
||||||
|
part_ids = [part['pk'] for part in response.data]
|
||||||
|
self.assertNotIn(2, part_ids)
|
||||||
|
self.assertNotIn(3, part_ids)
|
||||||
|
|
||||||
|
self.assertIn(1, part_ids)
|
||||||
|
|
||||||
|
# Test excluding with a part that has no relations
|
||||||
|
# This should return all parts
|
||||||
|
response = self.get(url, {'exclude_related': 99}, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), initial_count)
|
||||||
|
|
||||||
|
def test_exclude_id(self):
|
||||||
|
"""Test that we can exclude parts by ID using the exclude_id parameter."""
|
||||||
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
|
# Get initial count of all parts
|
||||||
|
response = self.get(url, {}, expected_code=200)
|
||||||
|
initial_count = len(response.data)
|
||||||
|
all_part_ids = {part['pk'] for part in response.data}
|
||||||
|
|
||||||
|
# Exclude a single valid part ID
|
||||||
|
response = self.get(url, {'exclude_id': '1'}, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), initial_count - 1)
|
||||||
|
part_ids = {part['pk'] for part in response.data}
|
||||||
|
self.assertNotIn(1, part_ids)
|
||||||
|
|
||||||
|
# Exclude multiple valid part IDs (comma-separated)
|
||||||
|
response = self.get(url, {'exclude_id': '1,2,3'}, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), initial_count - 3)
|
||||||
|
part_ids = {part['pk'] for part in response.data}
|
||||||
|
self.assertNotIn(1, part_ids)
|
||||||
|
self.assertNotIn(2, part_ids)
|
||||||
|
self.assertNotIn(3, part_ids)
|
||||||
|
|
||||||
|
# Exclude non-existent part ID (should not affect results)
|
||||||
|
non_existent_id = max(all_part_ids) + 1000
|
||||||
|
response = self.get(
|
||||||
|
url, {'exclude_id': str(non_existent_id)}, expected_code=200
|
||||||
|
)
|
||||||
|
self.assertEqual(len(response.data), initial_count)
|
||||||
|
|
||||||
|
# Exclude with empty string (should return all parts)
|
||||||
|
response = self.get(url, {'exclude_id': ''}, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), initial_count)
|
||||||
|
|
||||||
|
# Invalid input - non-numeric value (should raise ValidationError)
|
||||||
|
response = self.get(url, {'exclude_id': 'abc'}, expected_code=400)
|
||||||
|
|
||||||
|
# Zero as ID
|
||||||
|
response = self.get(url, {'exclude_id': '0'}, expected_code=200)
|
||||||
|
# Assuming 0 is not a valid part ID in the system
|
||||||
|
self.assertEqual(len(response.data), initial_count)
|
||||||
|
|
||||||
def test_filter_by_bom_valid(self):
|
def test_filter_by_bom_valid(self):
|
||||||
"""Test the 'bom_valid' Part API filter."""
|
"""Test the 'bom_valid' Part API filter."""
|
||||||
url = reverse('api-part-list')
|
url = reverse('api-part-list')
|
||||||
@@ -1904,6 +1980,16 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
|
|||||||
self.assertIn('category_path', response.data)
|
self.assertIn('category_path', response.data)
|
||||||
self.assertEqual(len(response.data['category_path']), 2)
|
self.assertEqual(len(response.data['category_path']), 2)
|
||||||
|
|
||||||
|
def test_location_detail(self):
|
||||||
|
"""Check that location_detail can be requested against the serializer."""
|
||||||
|
response = self.get(
|
||||||
|
reverse('api-part-detail', kwargs={'pk': 1}),
|
||||||
|
{'location_detail': True},
|
||||||
|
expected_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('default_location_detail', response.data)
|
||||||
|
|
||||||
def test_part_requirements(self):
|
def test_part_requirements(self):
|
||||||
"""Unit test for the "PartRequirements" API endpoint."""
|
"""Unit test for the "PartRequirements" API endpoint."""
|
||||||
url = reverse('api-part-requirements', kwargs={'pk': Part.objects.first().pk})
|
url = reverse('api-part-requirements', kwargs={'pk': Part.objects.first().pk})
|
||||||
@@ -2578,6 +2664,16 @@ class BomItemTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(int(float(response.data['quantity'])), 57)
|
self.assertEqual(int(float(response.data['quantity'])), 57)
|
||||||
self.assertEqual(response.data['note'], 'Added a note')
|
self.assertEqual(response.data['note'], 'Added a note')
|
||||||
|
|
||||||
|
def test_output_options(self):
|
||||||
|
"""Test that various output options work as expected."""
|
||||||
|
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
|
||||||
|
options = ['can_build', 'part_detail', 'sub_part_detail']
|
||||||
|
for option in options:
|
||||||
|
response = self.get(url, {f'{option}': True}, expected_code=200)
|
||||||
|
self.assertIn(option, response.data)
|
||||||
|
response = self.get(url, {f'{option}': False}, expected_code=200)
|
||||||
|
self.assertNotIn(option, response.data)
|
||||||
|
|
||||||
def test_add_bom_item(self):
|
def test_add_bom_item(self):
|
||||||
"""Test that we can create a new BomItem via the API."""
|
"""Test that we can create a new BomItem via the API."""
|
||||||
url = reverse('api-bom-list')
|
url = reverse('api-bom-list')
|
||||||
|
Reference in New Issue
Block a user