mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-03 07:42: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
|
||||
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."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v394 -> 2025-09-01 : https://github.com/inventree/InvenTree/pull/10438
|
||||
- Refactors 'bom_item_detail', 'assembly_detail', 'part_detail', 'build_detail' and 'allocations' param in user BuildLine API endpoint
|
||||
- Refactors 'part_detail', 'location_detail', 'stock_detail' and 'build_detail' param in user BuildItem API endpoint
|
||||
v395 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10441
|
||||
- Refactors 'parameters', 'category_detail', 'location_detail' and 'path_detail' params in Part 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
|
||||
- Refactors 'user_detail', 'permission_detail', 'role_detail' param in user GroupList API endpoint
|
||||
v394 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10438
|
||||
- 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
|
||||
- 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
|
||||
- Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint
|
||||
|
@@ -3,6 +3,7 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import make_aware
|
||||
|
||||
@@ -192,6 +193,33 @@ class NumberOrNullFilter(rest_filters.NumberFilter):
|
||||
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 = [
|
||||
drf_backend.DjangoFilterBackend,
|
||||
InvenTreeSearchFilter,
|
||||
|
@@ -22,6 +22,7 @@ from InvenTree.api import (
|
||||
ListCreateDestroyAPIView,
|
||||
MetadataView,
|
||||
)
|
||||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER,
|
||||
ORDER_FILTER_ALIAS,
|
||||
@@ -29,13 +30,16 @@ from InvenTree.filters import (
|
||||
SEARCH_ORDER_FILTER_ALIAS,
|
||||
InvenTreeDateFilter,
|
||||
InvenTreeSearchFilter,
|
||||
NumberOrNullFilter,
|
||||
NumericInFilter,
|
||||
)
|
||||
from InvenTree.helpers import isNull, str2bool
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.mixins import (
|
||||
CreateAPI,
|
||||
CustomRetrieveUpdateDestroyAPI,
|
||||
ListAPI,
|
||||
ListCreateAPI,
|
||||
OutputOptionsMixin,
|
||||
RetrieveAPI,
|
||||
RetrieveUpdateAPI,
|
||||
RetrieveUpdateDestroyAPI,
|
||||
@@ -67,17 +71,6 @@ class CategoryMixin:
|
||||
serializer_class = part_serializers.CategorySerializer
|
||||
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):
|
||||
"""Return an annotated queryset for the CategoryDetail endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
@@ -235,7 +228,19 @@ class CategoryFilter(FilterSet):
|
||||
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.
|
||||
|
||||
- GET: Return a list of PartCategory objects
|
||||
@@ -246,6 +251,8 @@ class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCrea
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
output_options = CategoryOutputOption
|
||||
|
||||
ordering_fields = ['name', 'pathstring', 'level', 'tree_id', 'lft', 'part_count']
|
||||
|
||||
# Use hierarchical ordering by default
|
||||
@@ -254,9 +261,11 @@ class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCrea
|
||||
search_fields = ['name', 'description', 'pathstring']
|
||||
|
||||
|
||||
class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
|
||||
class CategoryDetail(CategoryMixin, OutputOptionsMixin, CustomRetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a single PartCategory object."""
|
||||
|
||||
output_options = CategoryOutputOption
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Perform 'update' function and mark this part as 'starred' (or not)."""
|
||||
# Clean up input data
|
||||
@@ -909,6 +918,101 @@ class PartFilter(FilterSet):
|
||||
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:
|
||||
"""Mixin class for Part API endpoints."""
|
||||
@@ -952,17 +1056,6 @@ class PartMixin:
|
||||
|
||||
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)
|
||||
|
||||
def get_serializer_context(self):
|
||||
@@ -973,100 +1066,32 @@ class PartMixin:
|
||||
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."""
|
||||
|
||||
output_options = PartOutputOptions
|
||||
filterset_class = PartFilter
|
||||
is_create = True
|
||||
|
||||
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 '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.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."""
|
||||
|
||||
output_options = PartOutputOptions
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Custom update functionality for Part instance.
|
||||
|
||||
@@ -1594,23 +1621,7 @@ class BomMixin:
|
||||
queryset = BomItem.objects.all()
|
||||
|
||||
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
|
||||
"""
|
||||
# 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
|
||||
|
||||
"""Return the serializer instance for this API endpoint."""
|
||||
# Ensure the request context is passed through!
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
@@ -1625,13 +1636,26 @@ class BomMixin:
|
||||
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.
|
||||
|
||||
- GET: Return list of BomItem objects
|
||||
- POST: Create a new BomItem object
|
||||
"""
|
||||
|
||||
output_options = BomOutputOptions
|
||||
filterset_class = BomFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
@@ -1680,9 +1704,11 @@ class BomList(BomMixin, DataExportViewMixin, ListCreateDestroyAPIView):
|
||||
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."""
|
||||
|
||||
output_options = BomOutputOptions
|
||||
|
||||
|
||||
class BomItemValidate(UpdateAPI):
|
||||
"""API endpoint for validating a BomItem."""
|
||||
|
@@ -906,6 +906,82 @@ class PartAPITest(PartAPITestBase):
|
||||
response = self.get(url, {'related': 1}, expected_code=200)
|
||||
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):
|
||||
"""Test the 'bom_valid' Part API filter."""
|
||||
url = reverse('api-part-list')
|
||||
@@ -1904,6 +1980,16 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
|
||||
self.assertIn('category_path', response.data)
|
||||
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):
|
||||
"""Unit test for the "PartRequirements" API endpoint."""
|
||||
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(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):
|
||||
"""Test that we can create a new BomItem via the API."""
|
||||
url = reverse('api-bom-list')
|
||||
|
Reference in New Issue
Block a user