mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +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:
		| @@ -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. | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|     }, | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user