2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-14 21:22:20 +00:00

Refactor API endpoint: Stock (5/6) (#10444)

* add output options for StockLocation and StockItemTestResult endpoints

* add output options tests for StockLocation and StockItemTestResult endpoints

* add filters for include_installed and stock_item in StockItemTestResultFilter; update tests for new filter options

* update stock test.py

* update api_version.py

* fix test_installed_tests

* Enhance error handling and add tests for stock_item and include_installed filters

---------

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Reza
2025-10-04 02:49:00 +03:30
committed by GitHub
parent ed74c61e5c
commit 4f6256011b
5 changed files with 213 additions and 98 deletions

View File

@@ -1,12 +1,18 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 396
INVENTREE_API_VERSION = 397
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v397 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10444
- Refactors 'path_detail' param in StockLocation API endpoint
- Refactors 'user_detail' and 'template_detail' params in StockItemTestResult API endpoint
- Refactors 'item_detail' and 'user_detail' params in StockTracking API endpoint
- Refactors 'include_installed' and 'stock_item' params in StockItemTestResult API endpoint
v396 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10443
- Refactors 'part_detail', 'manufacturer_detail' and 'pretty' params in Manufacturer API endpoint
- Refactors 'manufacturer_part_detail' param in ManufacturerPartParameter API endpoint

View File

@@ -35,6 +35,7 @@ from InvenTree.api import (
ListCreateDestroyAPIView,
MetadataView,
)
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import (
ORDER_FILTER_ALIAS,
SEARCH_ORDER_FILTER,
@@ -48,6 +49,7 @@ from InvenTree.mixins import (
CustomRetrieveUpdateDestroyAPI,
ListAPI,
ListCreateAPI,
OutputOptionsMixin,
RetrieveAPI,
RetrieveUpdateDestroyAPI,
)
@@ -379,12 +381,6 @@ class StockLocationMixin:
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
try:
params = self.request.query_params
kwargs['path_detail'] = str2bool(params.get('path_detail', False))
except AttributeError: # pragma: no cover
pass
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
@@ -396,8 +392,23 @@ class StockLocationMixin:
return queryset
class StockLocationOutputOptions(OutputConfiguration):
"""Output options for StockLocation serializers."""
OPTIONS = [
InvenTreeOutputOption(
description='Include detailed information about the BOM item linked to this build line.',
flag='path_detail',
)
]
class StockLocationList(
DataExportViewMixin, BulkUpdateMixin, StockLocationMixin, ListCreateAPI
DataExportViewMixin,
BulkUpdateMixin,
StockLocationMixin,
OutputOptionsMixin,
ListCreateAPI,
):
"""API endpoint for list view of StockLocation objects.
@@ -407,6 +418,7 @@ class StockLocationList(
filterset_class = StockLocationFilter
filter_backends = SEARCH_ORDER_FILTER
output_options = StockLocationOutputOptions
search_fields = ['name', 'description', 'pathstring', 'tags__name', 'tags__slug']
@@ -415,9 +427,13 @@ class StockLocationList(
ordering = ['tree_id', 'lft', 'name']
class StockLocationDetail(StockLocationMixin, CustomRetrieveUpdateDestroyAPI):
class StockLocationDetail(
StockLocationMixin, OutputOptionsMixin, CustomRetrieveUpdateDestroyAPI
):
"""API endpoint for detail view of StockLocation object."""
output_options = StockLocationOutputOptions
def destroy(self, request, *args, **kwargs):
"""Delete a Stock location instance via the API."""
delete_stock_items = str(request.data.get('delete_stock_items', 0)) == '1'
@@ -1017,30 +1033,7 @@ class StockApiMixin:
return ctx
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer.
Extra detail may be provided to the serializer via query parameters:
- part_detail: Include detail about the StockItem's part
- location_detail: Include detail about the StockItem's location
- supplier_part_detail: Include detail about the StockItem's supplier_part
- tests: Include detail about the StockItem's test results
"""
try:
params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', True))
for key in [
'path_detail',
'location_detail',
'supplier_part_detail',
'tests',
]:
kwargs[key] = str2bool(params.get(key, False))
except AttributeError: # pragma: no cover
pass
"""Set context before returning serializer."""
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
@@ -1321,24 +1314,27 @@ class StockItemTestResultMixin:
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
try:
kwargs['user_detail'] = str2bool(
self.request.query_params.get('user_detail', False)
)
kwargs['template_detail'] = str2bool(
self.request.query_params.get('template_detail', False)
)
except Exception: # pragma: no cover
pass
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyAPI):
class StockItemTestResultOutputOptions(OutputConfiguration):
"""Output options for StockItemTestResult endpoint."""
OPTIONS = [
InvenTreeOutputOption(flag='user_detail'),
InvenTreeOutputOption(flag='template_detail'),
]
class StockItemTestResultDetail(
StockItemTestResultMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
):
"""Detail endpoint for StockItemTestResult."""
output_options = StockItemTestResultOutputOptions
class StockItemTestResultFilter(FilterSet):
"""API filter for the StockItemTestResult list."""
@@ -1381,52 +1377,71 @@ class StockItemTestResultFilter(FilterSet):
key = generateTestKey(value)
return queryset.filter(template__key=key)
include_installed = rest_filters.BooleanFilter(
method='filter_include_installed',
label=_('Include Installed'),
help_text=_(
'If true, include test results for items installed underneath the given stock item'
),
)
stock_item = rest_filters.NumberFilter(
method='filter_stock_item',
label=_('Stock Item'),
help_text=_('Filter by numeric Stock Item ID'),
)
def filter_include_installed(self, queryset, name, value):
"""Dummy filter method for 'include_installed'.
- Ensures 'include_installed' appears in API documentation
- Does NOT actually filter the queryset directly
- The actual logic is handled in filter_stock_item method
"""
return queryset
def filter_stock_item(self, queryset, name, value):
"""Filter for stock_item that also applies include_installed logic."""
include_installed = str2bool(self.data.get('include_installed', False))
try:
item = StockItem.objects.get(pk=value)
except StockItem.DoesNotExist:
raise ValidationError({
'stock_item': _('Stock item with ID {id} does not exist').format(
id=value
)
})
items = [item]
if include_installed:
# Include items which are installed "underneath" this item
# Note that this function is recursive!
installed_items = item.get_installed_items(cascade=True)
items += list(installed_items)
return queryset.filter(stock_item__in=items)
class StockItemTestResultList(
BulkCreateMixin, StockItemTestResultMixin, ListCreateDestroyAPIView
BulkCreateMixin,
StockItemTestResultMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
):
"""API endpoint for listing (and creating) a StockItemTestResult object."""
filterset_class = StockItemTestResultFilter
filter_backends = SEARCH_ORDER_FILTER
output_options = StockItemTestResultOutputOptions
filterset_fields = ['user', 'template', 'result', 'value']
ordering_fields = ['date', 'result']
ordering = 'date'
def filter_queryset(self, queryset):
"""Filter by build or stock_item."""
params = self.request.query_params
queryset = super().filter_queryset(queryset)
# Filter by stock item
item = params.get('stock_item', None)
if item is not None:
try:
item = StockItem.objects.get(pk=item)
items = [item]
# Do we wish to also include test results for 'installed' items?
include_installed = str2bool(params.get('include_installed', False))
if include_installed:
# Include items which are installed "underneath" this item
# Note that this function is recursive!
installed_items = item.get_installed_items(cascade=True)
items += list(installed_items)
queryset = queryset.filter(stock_item__in=items)
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
pass
return queryset
def perform_create(self, serializer):
"""Create a new test result object.
@@ -1446,7 +1461,16 @@ class StockTrackingDetail(RetrieveAPI):
serializer_class = StockSerializers.StockTrackingSerializer
class StockTrackingList(DataExportViewMixin, ListAPI):
class StockTrackingOutputOptions(OutputConfiguration):
"""Output options for StockItemTracking endpoint."""
OPTIONS = [
InvenTreeOutputOption(flag='item_detail'),
InvenTreeOutputOption(flag='user_detail'),
]
class StockTrackingList(DataExportViewMixin, OutputOptionsMixin, ListAPI):
"""API endpoint for list view of StockItemTracking objects.
StockItemTracking objects are read-only
@@ -1457,23 +1481,10 @@ class StockTrackingList(DataExportViewMixin, ListAPI):
queryset = StockItemTracking.objects.all()
serializer_class = StockSerializers.StockTrackingSerializer
output_options = StockTrackingOutputOptions
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
try:
kwargs['item_detail'] = str2bool(
self.request.query_params.get('item_detail', False)
)
except Exception: # pragma: no cover
pass
try:
kwargs['user_detail'] = str2bool(
self.request.query_params.get('user_detail', False)
)
except Exception: # pragma: no cover
pass
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)

View File

@@ -263,6 +263,7 @@
level: 0
lft: 1
rght: 2
belongs_to: 105
# Multiple stock items for "Bob" (PK 100)
- model: stock.stockitem

View File

@@ -263,6 +263,16 @@ class StockLocationTest(StockAPITestCase):
child.refresh_from_db()
self.assertEqual(child.parent, parent_stock_location)
def test_output_options(self):
"""Test output options."""
url = reverse('api-location-detail', kwargs={'pk': 1})
response = self.get(url, {'path_detail': 'true'}, expected_code=200)
self.assertIn('path', response.data)
response = self.get(url, {'path_detail': 'false'}, expected_code=200)
self.assertNotIn('path', response.data)
def test_stock_location_structural(self):
"""Test the effectiveness of structural stock locations.
@@ -777,18 +787,18 @@ class StockItemListTest(StockAPITestCase):
def test_filter_installed(self):
"""Filter StockItem by installed."""
response = self.get_stock(installed=True)
self.assertEqual(len(response), 0)
self.assertEqual(len(response), 1)
response = self.get_stock(installed=False)
self.assertEqual(len(response), 29) # TODO: adjust test dataset (belongs_to)
self.assertEqual(len(response), 28)
def test_filter_has_installed(self):
"""Filter StockItem by has_installed."""
response = self.get_stock(has_installed_items=True)
self.assertEqual(len(response), 0)
self.assertEqual(len(response), 1)
response = self.get_stock(has_installed_items=False)
self.assertEqual(len(response), 29) # TODO: adjust test dataset (belongs_to)
self.assertEqual(len(response), 28)
def test_filter_has_child_items(self):
"""Filter StockItem by has_child_items."""
@@ -1881,6 +1891,58 @@ class StockTestResultTest(StockAPITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertGreaterEqual(len(response.data), 4)
def test_stock_item_and_include_installed_filters(self):
"""Test stock_item filter with and without include_installed option."""
url = self.get_url()
# Filter by stock_item without include_installed
response = self.client.get(url, data={'stock_item': 105})
self.assertEqual(response.status_code, status.HTTP_200_OK)
initial_count = len(response.data)
self.assertGreaterEqual(initial_count, 4)
# Verify all results belong to stock_item 105
for result in response.data:
self.assertEqual(result['stock_item'], 105)
# Filter by stock_item with include_installed=True
response = self.client.get(
url, data={'stock_item': 105, 'include_installed': True}
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
installed_count = len(response.data)
self.assertGreaterEqual(installed_count, initial_count)
# Filter by stock_item with include_installed=False (explicit)
response = self.client.get(
url, data={'stock_item': 105, 'include_installed': False}
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), initial_count)
# Invalid stock_item ID (does not exist) - should return 400
response = self.client.get(url, data={'stock_item': 999999})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
# Non-numeric stock_item ID - should return 400
response = self.client.get(url, data={'stock_item': 'invalid'})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
# Filter by another stock_item (522)
response = self.client.get(url, data={'stock_item': 522})
self.assertEqual(response.status_code, status.HTTP_200_OK)
item_522_count = len(response.data)
self.assertGreaterEqual(item_522_count, 4)
# Verify all results belong to stock_item 522
for result in response.data:
self.assertEqual(result['stock_item'], 522)
# include_installed without stock_item (should be ignored)
response_all = self.client.get(url)
response_with_flag = self.client.get(url, data={'include_installed': True})
self.assertEqual(len(response_all.data), len(response_with_flag.data))
def test_post_fail(self):
"""Test failing posts."""
# Attempt to post a new test result without specifying required data
@@ -2129,6 +2191,22 @@ class StockTestResultTest(StockAPITestCase):
expected_code=201,
)
def test_output_options(self):
"""Test output options for single item retrieval."""
url = reverse('api-stock-test-result-detail', kwargs={'pk': 1})
response = self.get(url, {'user_detail': 'true'}, expected_code=200)
self.assertIn('user_detail', response.data)
response = self.get(url, {'user_detail': 'false'}, expected_code=200)
self.assertNotIn('user_detail', response.data)
response = self.get(url, {'template_detail': 'true'}, expected_code=200)
self.assertIn('template_detail', response.data)
response = self.get(url, {'template_detail': 'false'}, expected_code=200)
self.assertNotIn('template_detail', response.data)
class StockTrackingTest(StockAPITestCase):
"""Tests for the StockTracking API endpoints."""
@@ -2224,6 +2302,25 @@ class StockTrackingTest(StockAPITestCase):
self.assertIn(key, deltas)
self.assertIsNotNone(deltas.get(key, None))
def test_output_options(self):
"""Test output options."""
url = self.get_url()
response = self.client.get(
url, {'item_detail': True, 'user_detail': True, 'limit': 2}
)
for item in response.data['results']:
self.assertIn('item_detail', item)
self.assertIn('user_detail', item)
response = self.client.get(
url, {'item_detail': False, 'user_detail': False, 'limit': 2}
)
for item in response.data['results']:
self.assertNotIn('item_detail', item)
self.assertNotIn('user_detail', item)
class StockAssignTest(StockAPITestCase):
"""Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""

View File

@@ -1469,7 +1469,7 @@ class TestResultTest(StockTestBase):
# Should return the same number of tests as before
tests = item.testResultMap(include_installed=True)
self.assertEqual(len(tests), 3)
self.assertEqual(len(tests), 6)
if template := PartTestTemplate.objects.filter(
part=item.part, key='somenewtest'
@@ -1490,7 +1490,7 @@ class TestResultTest(StockTestBase):
)
tests = item.testResultMap(include_installed=True)
self.assertEqual(len(tests), 4)
self.assertEqual(len(tests), 7)
self.assertIn('somenewtest', tests)
self.assertEqual(sub_item.test_results.count(), 2)