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');