From 00017400ffa431addaf63ce28b9b3a86db729e04 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 8 Aug 2025 07:19:20 +1000 Subject: [PATCH] Bulk add test results (#10146) * Bulk creation of test results - Add BulkCreateMixin class - Add frontend support * Refactor test result serializer - Allow lookup by template name * Updated unit test * Add unit tests * Add row actions * Docs * Fix failing tests * Bump API version * Fix playwright tests --- docs/docs/manufacturing/build.md | 16 ++++ docs/docs/settings/global.md | 1 - src/backend/InvenTree/InvenTree/api.py | 29 ++++++ .../InvenTree/InvenTree/api_version.py | 6 +- .../InvenTree/common/setting/system.py | 8 -- src/backend/InvenTree/stock/api.py | 11 ++- src/backend/InvenTree/stock/serializers.py | 29 +++--- src/backend/InvenTree/stock/test_api.py | 95 ++++++++++++------- src/frontend/src/components/forms/ApiForm.tsx | 12 +-- .../src/components/render/Instance.tsx | 6 +- src/frontend/src/components/render/Part.tsx | 2 +- .../pages/Index/Settings/SystemSettings.tsx | 3 +- .../src/tables/build/BuildOrderTestTable.tsx | 86 ++++++++++++++++- src/frontend/tests/pages/pui_build.spec.ts | 11 ++- 14 files changed, 237 insertions(+), 78 deletions(-) diff --git a/docs/docs/manufacturing/build.md b/docs/docs/manufacturing/build.md index ae01395f69..3a8b5b8fc8 100644 --- a/docs/docs/manufacturing/build.md +++ b/docs/docs/manufacturing/build.md @@ -187,6 +187,22 @@ For *trackable* parts, test results can be recorded against each build output. T This table provides a summary of the test results for each build output, and allows test results to be quickly added for each build output. +### Adding Test Results + +There are multiple ways to add test results against build outputs from this table view: + +#### Row Actions + +Open the row actions menu for a specific build output, and select the *Add Test Result* option. + +#### Table Buttons + +Each available test is rendered in the table as a separate column. Any output which does not already have a result registered for that test will display a button in the table cell, labelled *Add Test Result*. Clicking on this button will open the *Add Test Result* dialog for that test. + +#### Bulk Add Test Results + +Select the build outputs for which you wish to add test results, then click on the *Add Test Results* button at the top of the table. This will open the *Add Test Result* dialog, allowing you to select the test and enter the result for all selected outputs. + ### Attachments Files attachments can be uploaded against the build order, and displayed in the *Attachments* tab: diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index 93301810ba..0c9ac2db0f 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -211,7 +211,6 @@ Configuration of stock item options {{ globalsetting("STOCK_ENFORCE_BOM_INSTALLATION") }} {{ globalsetting("STOCK_ALLOW_OUT_OF_STOCK_TRANSFER") }} {{ globalsetting("TEST_STATION_DATA") }} -{{ globalsetting("TEST_UPLOAD_CREATE_TEMPLATE") }} ### Build Orders diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index afcaa6422f..24da3d619a 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -474,6 +474,35 @@ class BulkOperationMixin: return queryset +class BulkCreateMixin: + """Mixin class for enabling 'bulk create' operations for various models. + + Bulk create allows for multiple items to be created in a single API query, + rather than using multiple API calls to same endpoint. + """ + + def create(self, request, *args, **kwargs): + """Perform a POST operation against this list endpoint.""" + data = request.data + + if isinstance(data, list): + created_items = [] + + # If data is a list, we assume it is a bulk create request + if len(data) == 0: + raise ValidationError({'non_field_errors': _('No data provided')}) + + for item in data: + serializer = self.get_serializer(data=item) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + created_items.append(serializer.data) + + return Response(created_items, status=201) + + return super().create(request, *args, **kwargs) + + class BulkUpdateMixin(BulkOperationMixin): """Mixin class for enabling 'bulk update' operations for various models. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 6c3e7f0fae..2ab51d39b1 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 381 +INVENTREE_API_VERSION = 382 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v382 -> 2025-08-07 : https://github.com/inventree/InvenTree/pull/10146 + - Adds ability to "bulk create" test results via the API + - Removes legacy functionality to auto-create test result templates based on provided test names + v381 -> 2025-08-06 : https://github.com/inventree/InvenTree/pull/10132 - Refactor the "return stock item" API endpoint to align with other stock adjustment actions diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 781425256a..52a0f72e87 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -1124,12 +1124,4 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, }, - 'TEST_UPLOAD_CREATE_TEMPLATE': { - 'name': _('Create Template on Upload'), - 'description': _( - 'Create a new test template when uploading test data which does not match an existing template' - ), - 'default': True, - 'validator': bool, - }, } diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 37faee8bff..a35b299c08 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -28,7 +28,12 @@ from company.models import Company, SupplierPart from company.serializers import CompanySerializer from data_exporter.mixins import DataExportViewMixin from generic.states.api import StatusView -from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView +from InvenTree.api import ( + BulkCreateMixin, + BulkUpdateMixin, + ListCreateDestroyAPIView, + MetadataView, +) from InvenTree.filters import ( ORDER_FILTER_ALIAS, SEARCH_ORDER_FILTER, @@ -1360,7 +1365,9 @@ class StockItemTestResultFilter(rest_filters.FilterSet): return queryset.filter(template__key=key) -class StockItemTestResultList(StockItemTestResultMixin, ListCreateDestroyAPIView): +class StockItemTestResultList( + BulkCreateMixin, StockItemTestResultMixin, ListCreateDestroyAPIView +): """API endpoint for listing (and creating) a StockItemTestResult object.""" filterset_class = StockItemTestResultFilter diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index a080b62780..4e0e868f69 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -263,15 +263,18 @@ class StockItemTestResultSerializer( """Validate the test result data.""" stock_item = data['stock_item'] template = data.get('template', None) - - # To support legacy API, we can accept a test name instead of a template - # In such a case, we use the test name to lookup the appropriate template - test_name = self.context['request'].data.get('test', None) - - if not template and not test_name: - raise ValidationError(_('Template ID or test name must be provided')) + test_name = None if not template: + # To support legacy API, we can accept a test name instead of a template + # In such a case, we use the test name to lookup the appropriate template + request_data = self.context['request'].data + + if type(request_data) is list and len(request_data) > 0: + request_data = request_data[0] + + test_name = request_data.get('test', test_name) + test_key = InvenTree.helpers.generateTestKey(test_name) ancestors = stock_item.part.get_ancestors(include_self=True) @@ -282,16 +285,8 @@ class StockItemTestResultSerializer( ).first(): data['template'] = template - elif get_global_setting('TEST_UPLOAD_CREATE_TEMPLATE', False): - logger.debug( - "No matching test template found for '%s' - creating a new template", - test_name, - ) - - # Create a new test template based on the provided data - data['template'] = part_models.PartTestTemplate.objects.create( - part=stock_item.part, test_name=test_name - ) + if not template: + raise ValidationError(_('Template ID or test name must be provided')) data = super().validate(data) diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index a12f01b1d8..825de0cef8 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -1882,56 +1882,43 @@ class StockTestResultTest(StockAPITestCase): self.post(url, data={'test': 'A test', 'result': True}, expected_code=400) - # This one should pass! + # This one should fail (no matching test template) self.post( url, data={'test': 'A test', 'stock_item': 105, 'result': True}, - expected_code=201, + expected_code=400, ) def test_post(self): """Test creation of a new test result.""" url = self.get_url() - response = self.client.get(url) - n = len(response.data) + item = StockItem.objects.get(pk=105) + part = item.part + + # Create a new test template for this part + test_template = PartTestTemplate.objects.create( + part=part, + test_name='Checked Steam Valve', + description='Test to check the steam valve pressure', + ) # Test upload using test name (legacy method) - # Note that a new test template will be created data = { 'stock_item': 105, - 'test': 'Checked Steam Valve', + 'test': 'checkedsteamvalve', 'result': False, 'value': '150kPa', 'notes': 'I guess there was just too much pressure?', } - # First, test with TEST_UPLOAD_CREATE_TEMPLATE set to False - InvenTreeSetting.set_setting('TEST_UPLOAD_CREATE_TEMPLATE', False, self.user) + data = self.post(url, data, expected_code=201).data - response = self.post(url, data, expected_code=400) + self.assertEqual(data['result'], False) + self.assertEqual(data['stock_item'], 105) + self.assertEqual(data['template'], test_template.pk) - # Again, with the setting enabled - InvenTreeSetting.set_setting('TEST_UPLOAD_CREATE_TEMPLATE', True, self.user) - - response = self.post(url, data, expected_code=201) - - # Check that a new test template has been created - test_template = PartTestTemplate.objects.get(key='checkedsteamvalve') - - response = self.client.get(url) - self.assertEqual(len(response.data), n + 1) - - # And read out again - response = self.client.get(url, data={'test': 'Checked Steam Valve'}) - - self.assertEqual(len(response.data), 1) - - test = response.data[0] - self.assertEqual(test['value'], '150kPa') - self.assertEqual(test['user'], self.user.pk) - - # Test upload using template reference (new method) + # Test upload using template reference data = { 'stock_item': 105, 'template': test_template.pk, @@ -1941,7 +1928,7 @@ class StockTestResultTest(StockAPITestCase): response = self.post(url, data, expected_code=201) - # Check that a new test template has been created + # Check that a new test result has been created self.assertEqual(test_template.test_results.all().count(), 2) # List test results against the template @@ -1952,6 +1939,43 @@ class StockTestResultTest(StockAPITestCase): for item in response.data: self.assertEqual(item['template'], test_template.pk) + def test_bulk_create(self): + """Test bulk creation of test results against the API.""" + url = self.get_url() + + test_template = PartTestTemplate.objects.get(pk=9) + part = test_template.part + + N = test_template.test_results.count() + + location = StockLocation.objects.filter(structural=False).first() + + stock_items = [ + StockItem.objects.create(part=part, quantity=1, location=location) + for _ in range(10) + ] + + # Generate data to bulk-create test results + test_data = [ + { + 'stock_item': item.pk, + 'template': test_template.pk, + 'result': True, + 'value': f'Test value: {item.pk}', + } + for item in stock_items + ] + + data = self.post(url, data=test_data, expected_code=201).data + + self.assertEqual(len(data), 10) + self.assertEqual(test_template.test_results.count(), N + 10) + + for item in data: + item_id = item['stock_item'] + self.assertEqual(item['template'], test_template.pk) + self.assertEqual(item['value'], f'Test value: {item_id}') + def test_post_bitmap(self): """2021-08-25. @@ -1998,18 +2022,25 @@ class StockTestResultTest(StockAPITestCase): p.testable = True p.save() + # Create a test template to record test results against + test_template = PartTestTemplate.objects.create( + part=p, test_name='Test Template', description='A test template for testing' + ) + # Create some objects (via the API) for _ii in range(50): response = self.post( url, { 'stock_item': stock_item.pk, - 'test': f'Some test {_ii}', + 'test': test_template.key, 'result': True, 'value': 'Test result value', }, ) + self.assertEqual(response.data['template'], test_template.pk) + tests.append(response.data['pk']) self.assertEqual(StockItemTestResult.objects.count(), n + 50) diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 73484afc4f..365a6eb056 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -356,12 +356,7 @@ export function ApiForm({ let hasFiles = false; - // Optionally pre-process the data before submitting it - if (props.processFormData) { - data = props.processFormData(data, form); - } - - const jsonData = { ...data }; + let jsonData = { ...data }; const formData = new FormData(); Object.keys(data).forEach((key: string) => { @@ -397,6 +392,11 @@ export function ApiForm({ } }); + // Optionally pre-process the data before submitting it + if (props.processFormData) { + jsonData = props.processFormData(jsonData, form); + } + /* Set the timeout for the request: * - If a timeout is provided in the props, use that * - If the form contains files, use a longer timeout diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index 06e1c29272..0976f3d2db 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -209,6 +209,10 @@ export function RenderInlineModel({ } } + if (typeof suffix === 'string') { + suffix = {suffix}; + } + return ( @@ -226,7 +230,7 @@ export function RenderInlineModel({ {suffix && ( <> -
{suffix}
+ {suffix} )}
diff --git a/src/frontend/src/components/render/Part.tsx b/src/frontend/src/components/render/Part.tsx index 613740b58d..61e77e6cd1 100644 --- a/src/frontend/src/components/render/Part.tsx +++ b/src/frontend/src/components/render/Part.tsx @@ -125,7 +125,7 @@ export function RenderPartTestTemplate({ return ( ); } diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 1b0f687608..1085af3d51 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -237,8 +237,7 @@ export default function SystemSettings() { 'STOCK_SHOW_INSTALLED_ITEMS', 'STOCK_ENFORCE_BOM_INSTALLATION', 'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', - 'TEST_STATION_DATA', - 'TEST_UPLOAD_CREATE_TEMPLATE' + 'TEST_STATION_DATA' ]} /> ) diff --git a/src/frontend/src/tables/build/BuildOrderTestTable.tsx b/src/frontend/src/tables/build/BuildOrderTestTable.tsx index cdb5560307..99a4f27d44 100644 --- a/src/frontend/src/tables/build/BuildOrderTestTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTestTable.tsx @@ -2,16 +2,24 @@ import { t } from '@lingui/core/macro'; import { ActionIcon, Badge, Group, Text, Tooltip } from '@mantine/core'; import { IconCirclePlus } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState +} from 'react'; import { PassFailButton } from '@lib/components/YesNoButton'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; import { cancelEvent } from '@lib/functions/Events'; +import { AddItemButton } from '@lib/index'; import type { TableFilter } from '@lib/types/Filters'; import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { TableColumn } from '@lib/types/Tables'; +import type { UseFormReturn } from 'react-hook-form'; import { RenderUser } from '../../components/render/User'; import { useApi } from '../../contexts/ApiContext'; import { formatDate } from '../../defaults/formatters'; @@ -62,7 +70,9 @@ export default function BuildOrderTestTable({ }, [testTemplates]); const [selectedOutput, setSelectedOutput] = useState(0); - const [selectedTemplate, setSelectedTemplate] = useState(0); + const [selectedTemplate, setSelectedTemplate] = useState( + undefined + ); const testResultFields: ApiFormFieldSet = useTestResultFields({ partId: partId, @@ -82,6 +92,48 @@ export default function BuildOrderTestTable({ successMessage: t`Test result added` }); + const multipleTestResultFields: ApiFormFieldSet = useMemo(() => { + const fields: ApiFormFieldSet = { ...testResultFields }; + + // Do not allow attachment for multiple test results + delete fields.attachment; + delete fields.stock_item; + + fields.template.disabled = false; + + return fields; + }, [partId, testResultFields]); + + const generateTestResults = useCallback( + (data: any, form: UseFormReturn) => { + // Generate a list of test results for each selected output + const results = table.selectedRecords.map((record: any) => { + return { + ...data, + stock_item: record.pk + }; + }); + + return results; + }, + [table.selectedIds] + ); + + const createTestResultMultiple = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.stock_test_result_list), + title: t`Add Test Results`, + fields: multipleTestResultFields, + initialData: { + result: true + }, + onFormSuccess: () => { + table.clearSelectedRecords(); + table.refreshTable(); + }, + processFormData: generateTestResults, + successMessage: t`Test results added` + }); + // Generate a table column for each test template const testColumns: TableColumn[] = useMemo(() => { if (!testTemplates || testTemplates.length == 0) { @@ -112,6 +164,7 @@ export default function BuildOrderTestTable({ { cancelEvent(event); @@ -224,12 +277,37 @@ export default function BuildOrderTestTable({ }, []); const tableActions = useMemo(() => { - return []; + return [ + { + createTestResultMultiple.open(); + }} + /> + ]; + }, [table.hasSelectedRecords]); + + const rowActions = useCallback((record: any) => { + return [ + { + icon: , + color: 'green', + title: t`Add Test Result`, + onClick: (event: any) => { + setSelectedOutput(record.pk); + setSelectedTemplate(undefined); + createTestResult.open(); + } + } + ]; }, []); return ( <> {createTestResult.modal} + {createTestResultMultiple.modal} { await loadTab(page, 'Test Results'); await page.getByText('Quantity: 25').waitFor(); await page.getByText('Continuity Checks').waitFor(); - await page + + const button = await page .getByRole('row', { name: 'Quantity: 16' }) - .getByRole('button') - .hover(); - await page.getByText('Add Test Result').waitFor(); + .getByLabel('add-test-result'); + + await button.click(); + await page.getByRole('textbox', { name: 'text-field-value' }).waitFor(); + await page.getByRole('button', { name: 'Cancel' }).click(); // Click through to the "parent" build await loadTab(page, 'Build Details');