2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-28 20:07:39 +00:00

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
This commit is contained in:
Oliver
2025-08-08 07:19:20 +10:00
committed by GitHub
parent 7df8e06f36
commit 00017400ff
14 changed files with 237 additions and 78 deletions

View File

@@ -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. 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 ### Attachments
Files attachments can be uploaded against the build order, and displayed in the *Attachments* tab: Files attachments can be uploaded against the build order, and displayed in the *Attachments* tab:

View File

@@ -211,7 +211,6 @@ Configuration of stock item options
{{ globalsetting("STOCK_ENFORCE_BOM_INSTALLATION") }} {{ globalsetting("STOCK_ENFORCE_BOM_INSTALLATION") }}
{{ globalsetting("STOCK_ALLOW_OUT_OF_STOCK_TRANSFER") }} {{ globalsetting("STOCK_ALLOW_OUT_OF_STOCK_TRANSFER") }}
{{ globalsetting("TEST_STATION_DATA") }} {{ globalsetting("TEST_STATION_DATA") }}
{{ globalsetting("TEST_UPLOAD_CREATE_TEMPLATE") }}
### Build Orders ### Build Orders

View File

@@ -474,6 +474,35 @@ class BulkOperationMixin:
return queryset 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): class BulkUpdateMixin(BulkOperationMixin):
"""Mixin class for enabling 'bulk update' operations for various models. """Mixin class for enabling 'bulk update' operations for various models.

View File

@@ -1,12 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """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 = """
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 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 - Refactor the "return stock item" API endpoint to align with other stock adjustment actions

View File

@@ -1124,12 +1124,4 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, '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,
},
} }

View File

@@ -28,7 +28,12 @@ from company.models import Company, SupplierPart
from company.serializers import CompanySerializer from company.serializers import CompanySerializer
from data_exporter.mixins import DataExportViewMixin from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView 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 ( from InvenTree.filters import (
ORDER_FILTER_ALIAS, ORDER_FILTER_ALIAS,
SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER,
@@ -1360,7 +1365,9 @@ class StockItemTestResultFilter(rest_filters.FilterSet):
return queryset.filter(template__key=key) return queryset.filter(template__key=key)
class StockItemTestResultList(StockItemTestResultMixin, ListCreateDestroyAPIView): class StockItemTestResultList(
BulkCreateMixin, StockItemTestResultMixin, ListCreateDestroyAPIView
):
"""API endpoint for listing (and creating) a StockItemTestResult object.""" """API endpoint for listing (and creating) a StockItemTestResult object."""
filterset_class = StockItemTestResultFilter filterset_class = StockItemTestResultFilter

View File

@@ -263,15 +263,18 @@ class StockItemTestResultSerializer(
"""Validate the test result data.""" """Validate the test result data."""
stock_item = data['stock_item'] stock_item = data['stock_item']
template = data.get('template', None) template = data.get('template', None)
test_name = 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'))
if not template: 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) test_key = InvenTree.helpers.generateTestKey(test_name)
ancestors = stock_item.part.get_ancestors(include_self=True) ancestors = stock_item.part.get_ancestors(include_self=True)
@@ -282,16 +285,8 @@ class StockItemTestResultSerializer(
).first(): ).first():
data['template'] = template data['template'] = template
elif get_global_setting('TEST_UPLOAD_CREATE_TEMPLATE', False): if not template:
logger.debug( raise ValidationError(_('Template ID or test name must be provided'))
"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
)
data = super().validate(data) data = super().validate(data)

View File

@@ -1882,56 +1882,43 @@ class StockTestResultTest(StockAPITestCase):
self.post(url, data={'test': 'A test', 'result': True}, expected_code=400) 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( self.post(
url, url,
data={'test': 'A test', 'stock_item': 105, 'result': True}, data={'test': 'A test', 'stock_item': 105, 'result': True},
expected_code=201, expected_code=400,
) )
def test_post(self): def test_post(self):
"""Test creation of a new test result.""" """Test creation of a new test result."""
url = self.get_url() url = self.get_url()
response = self.client.get(url) item = StockItem.objects.get(pk=105)
n = len(response.data) 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) # Test upload using test name (legacy method)
# Note that a new test template will be created
data = { data = {
'stock_item': 105, 'stock_item': 105,
'test': 'Checked Steam Valve', 'test': 'checkedsteamvalve',
'result': False, 'result': False,
'value': '150kPa', 'value': '150kPa',
'notes': 'I guess there was just too much pressure?', 'notes': 'I guess there was just too much pressure?',
} }
# First, test with TEST_UPLOAD_CREATE_TEMPLATE set to False data = self.post(url, data, expected_code=201).data
InvenTreeSetting.set_setting('TEST_UPLOAD_CREATE_TEMPLATE', False, self.user)
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 # Test upload using template reference
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)
data = { data = {
'stock_item': 105, 'stock_item': 105,
'template': test_template.pk, 'template': test_template.pk,
@@ -1941,7 +1928,7 @@ class StockTestResultTest(StockAPITestCase):
response = self.post(url, data, expected_code=201) 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) self.assertEqual(test_template.test_results.all().count(), 2)
# List test results against the template # List test results against the template
@@ -1952,6 +1939,43 @@ class StockTestResultTest(StockAPITestCase):
for item in response.data: for item in response.data:
self.assertEqual(item['template'], test_template.pk) 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): def test_post_bitmap(self):
"""2021-08-25. """2021-08-25.
@@ -1998,18 +2022,25 @@ class StockTestResultTest(StockAPITestCase):
p.testable = True p.testable = True
p.save() 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) # Create some objects (via the API)
for _ii in range(50): for _ii in range(50):
response = self.post( response = self.post(
url, url,
{ {
'stock_item': stock_item.pk, 'stock_item': stock_item.pk,
'test': f'Some test {_ii}', 'test': test_template.key,
'result': True, 'result': True,
'value': 'Test result value', 'value': 'Test result value',
}, },
) )
self.assertEqual(response.data['template'], test_template.pk)
tests.append(response.data['pk']) tests.append(response.data['pk'])
self.assertEqual(StockItemTestResult.objects.count(), n + 50) self.assertEqual(StockItemTestResult.objects.count(), n + 50)

View File

@@ -356,12 +356,7 @@ export function ApiForm({
let hasFiles = false; let hasFiles = false;
// Optionally pre-process the data before submitting it let jsonData = { ...data };
if (props.processFormData) {
data = props.processFormData(data, form);
}
const jsonData = { ...data };
const formData = new FormData(); const formData = new FormData();
Object.keys(data).forEach((key: string) => { 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: /* Set the timeout for the request:
* - If a timeout is provided in the props, use that * - If a timeout is provided in the props, use that
* - If the form contains files, use a longer timeout * - If the form contains files, use a longer timeout

View File

@@ -209,6 +209,10 @@ export function RenderInlineModel({
} }
} }
if (typeof suffix === 'string') {
suffix = <Text size='xs'>{suffix}</Text>;
}
return ( return (
<Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}> <Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}>
<Group gap='xs' justify='left' wrap='nowrap'> <Group gap='xs' justify='left' wrap='nowrap'>
@@ -226,7 +230,7 @@ export function RenderInlineModel({
{suffix && ( {suffix && (
<> <>
<Space /> <Space />
<div style={{ fontSize: 'xs', lineHeight: 'xs' }}>{suffix}</div> {suffix}
</> </>
)} )}
</Group> </Group>

View File

@@ -125,7 +125,7 @@ export function RenderPartTestTemplate({
return ( return (
<RenderInlineModel <RenderInlineModel
primary={instance.test_name} primary={instance.test_name}
secondary={instance.description} suffix={instance.description}
/> />
); );
} }

View File

@@ -237,8 +237,7 @@ export default function SystemSettings() {
'STOCK_SHOW_INSTALLED_ITEMS', 'STOCK_SHOW_INSTALLED_ITEMS',
'STOCK_ENFORCE_BOM_INSTALLATION', 'STOCK_ENFORCE_BOM_INSTALLATION',
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', 'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER',
'TEST_STATION_DATA', 'TEST_STATION_DATA'
'TEST_UPLOAD_CREATE_TEMPLATE'
]} ]}
/> />
) )

View File

@@ -2,16 +2,24 @@ import { t } from '@lingui/core/macro';
import { ActionIcon, Badge, Group, Text, Tooltip } from '@mantine/core'; import { ActionIcon, Badge, Group, Text, Tooltip } from '@mantine/core';
import { IconCirclePlus } from '@tabler/icons-react'; import { IconCirclePlus } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; 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 { PassFailButton } from '@lib/components/YesNoButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { cancelEvent } from '@lib/functions/Events'; import { cancelEvent } from '@lib/functions/Events';
import { AddItemButton } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables'; import type { TableColumn } from '@lib/types/Tables';
import type { UseFormReturn } from 'react-hook-form';
import { RenderUser } from '../../components/render/User'; import { RenderUser } from '../../components/render/User';
import { useApi } from '../../contexts/ApiContext'; import { useApi } from '../../contexts/ApiContext';
import { formatDate } from '../../defaults/formatters'; import { formatDate } from '../../defaults/formatters';
@@ -62,7 +70,9 @@ export default function BuildOrderTestTable({
}, [testTemplates]); }, [testTemplates]);
const [selectedOutput, setSelectedOutput] = useState<number>(0); const [selectedOutput, setSelectedOutput] = useState<number>(0);
const [selectedTemplate, setSelectedTemplate] = useState<number>(0); const [selectedTemplate, setSelectedTemplate] = useState<number | undefined>(
undefined
);
const testResultFields: ApiFormFieldSet = useTestResultFields({ const testResultFields: ApiFormFieldSet = useTestResultFields({
partId: partId, partId: partId,
@@ -82,6 +92,48 @@ export default function BuildOrderTestTable({
successMessage: t`Test result added` 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 // Generate a table column for each test template
const testColumns: TableColumn[] = useMemo(() => { const testColumns: TableColumn[] = useMemo(() => {
if (!testTemplates || testTemplates.length == 0) { if (!testTemplates || testTemplates.length == 0) {
@@ -112,6 +164,7 @@ export default function BuildOrderTestTable({
<ActionIcon <ActionIcon
size='lg' size='lg'
color='green' color='green'
aria-label='add-test-result'
variant='transparent' variant='transparent'
onClick={(event: any) => { onClick={(event: any) => {
cancelEvent(event); cancelEvent(event);
@@ -224,12 +277,37 @@ export default function BuildOrderTestTable({
}, []); }, []);
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return []; return [
<AddItemButton
key='add-test-result'
tooltip={t`Add Test Result`}
disabled={!table.hasSelectedRecords}
onClick={(event: any) => {
createTestResultMultiple.open();
}}
/>
];
}, [table.hasSelectedRecords]);
const rowActions = useCallback((record: any) => {
return [
{
icon: <IconCirclePlus />,
color: 'green',
title: t`Add Test Result`,
onClick: (event: any) => {
setSelectedOutput(record.pk);
setSelectedTemplate(undefined);
createTestResult.open();
}
}
];
}, []); }, []);
return ( return (
<> <>
{createTestResult.modal} {createTestResult.modal}
{createTestResultMultiple.modal}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.stock_item_list)} url={apiUrl(ApiEndpoints.stock_item_list)}
tableState={table} tableState={table}
@@ -241,6 +319,8 @@ export default function BuildOrderTestTable({
tests: true, tests: true,
build: buildId build: buildId
}, },
enableSelection: true,
rowActions: rowActions,
tableFilters: tableFilters, tableFilters: tableFilters,
tableActions: tableActions, tableActions: tableActions,
modelType: ModelType.stockitem modelType: ModelType.stockitem

View File

@@ -79,11 +79,14 @@ test('Build Order - Basic Tests', async ({ browser }) => {
await loadTab(page, 'Test Results'); await loadTab(page, 'Test Results');
await page.getByText('Quantity: 25').waitFor(); await page.getByText('Quantity: 25').waitFor();
await page.getByText('Continuity Checks').waitFor(); await page.getByText('Continuity Checks').waitFor();
await page
const button = await page
.getByRole('row', { name: 'Quantity: 16' }) .getByRole('row', { name: 'Quantity: 16' })
.getByRole('button') .getByLabel('add-test-result');
.hover();
await page.getByText('Add Test Result').waitFor(); 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 // Click through to the "parent" build
await loadTab(page, 'Build Details'); await loadTab(page, 'Build Details');