diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 2827a9000919..7aa54e73a274 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -273,13 +273,14 @@ 'INVENTREE_DEBUG_QUERYCOUNT', 'debug_querycount', False ): MIDDLEWARE.append('querycount.middleware.QueryCountMiddleware') + logger.debug('Running with debug_querycount middleware enabled') QUERYCOUNT = { 'THRESHOLDS': { 'MEDIUM': 50, 'HIGH': 200, - 'MIN_TIME_TO_LOG': 0, - 'MIN_QUERY_COUNT_TO_LOG': 0, + 'MIN_TIME_TO_LOG': 0.1, + 'MIN_QUERY_COUNT_TO_LOG': 25, }, 'IGNORE_REQUEST_PATTERNS': ['^(?!\/(api)?(plugin)?\/).*'], 'IGNORE_SQL_PATTERNS': [], diff --git a/src/backend/InvenTree/InvenTree/unit_test.py b/src/backend/InvenTree/InvenTree/unit_test.py index ea7b559511b5..4576cdce6e76 100644 --- a/src/backend/InvenTree/InvenTree/unit_test.py +++ b/src/backend/InvenTree/InvenTree/unit_test.py @@ -4,6 +4,7 @@ import io import json import re +import time from contextlib import contextmanager from pathlib import Path @@ -228,10 +229,19 @@ class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase): class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): """Base class for running InvenTree API tests.""" + # Default query count threshold value + # TODO: This value should be reduced + MAX_QUERY_COUNT = 250 + + WARNING_QUERY_THRESHOLD = 100 + + # Default query time threshold value + # TODO: This value should be reduced + # Note: There is a lot of variability in the query time in unit testing... + MAX_QUERY_TIME = 7.5 + @contextmanager - def assertNumQueriesLessThan( - self, value, using='default', verbose=False, debug=False - ): + def assertNumQueriesLessThan(self, value, using='default', verbose=None, url=None): """Context manager to check that the number of queries is less than a certain value. Example: @@ -242,6 +252,13 @@ def assertNumQueriesLessThan( with CaptureQueriesContext(connections[using]) as context: yield # your test will be run here + n = len(context.captured_queries) + + if url and n >= value: + print( + f'Query count exceeded at {url}: Expected < {value} queries, got {n}' + ) # pragma: no cover + if verbose: msg = '\r\n%s' % json.dumps( context.captured_queries, indent=4 @@ -249,34 +266,29 @@ def assertNumQueriesLessThan( else: msg = None - n = len(context.captured_queries) - - if debug: - print( - f'Expected less than {value} queries, got {n} queries' - ) # pragma: no cover + if url and n > self.WARNING_QUERY_THRESHOLD: + print(f'Warning: {n} queries executed at {url}') self.assertLess(n, value, msg=msg) - def checkResponse(self, url, method, expected_code, response): + def check_response(self, url, response, expected_code=None): """Debug output for an unexpected response.""" - # No expected code, return - if expected_code is None: - return + # Check that the response returned the expected status code - if expected_code != response.status_code: # pragma: no cover - print( - f"Unexpected {method} response at '{url}': status_code = {response.status_code}" - ) + if expected_code is not None: + if expected_code != response.status_code: # pragma: no cover + print( + f"Unexpected response at '{url}': status_code = {response.status_code} (expected {expected_code})" + ) - if hasattr(response, 'data'): - print('data:', response.data) - if hasattr(response, 'body'): - print('body:', response.body) - if hasattr(response, 'content'): - print('content:', response.content) + if hasattr(response, 'data'): + print('data:', response.data) + if hasattr(response, 'body'): + print('body:', response.body) + if hasattr(response, 'content'): + print('content:', response.content) - self.assertEqual(expected_code, response.status_code) + self.assertEqual(expected_code, response.status_code) def getActions(self, url): """Return a dict of the 'actions' available at a given endpoint. @@ -289,72 +301,88 @@ def getActions(self, url): actions = response.data.get('actions', {}) return actions - def get(self, url, data=None, expected_code=200, format='json', **kwargs): - """Issue a GET request.""" - # Set default - see B006 + def query(self, url, method, data=None, **kwargs): + """Perform a generic API query.""" if data is None: data = {} - response = self.client.get(url, data, format=format, **kwargs) + expected_code = kwargs.pop('expected_code', None) - self.checkResponse(url, 'GET', expected_code, response) + kwargs['format'] = kwargs.get('format', 'json') - return response + max_queries = kwargs.get('max_query_count', self.MAX_QUERY_COUNT) + max_query_time = kwargs.get('max_query_time', self.MAX_QUERY_TIME) - def post(self, url, data=None, expected_code=None, format='json', **kwargs): - """Issue a POST request.""" - # Set default value - see B006 - if data is None: - data = {} + t1 = time.time() - response = self.client.post(url, data=data, format=format, **kwargs) + with self.assertNumQueriesLessThan(max_queries, url=url): + response = method(url, data, **kwargs) + t2 = time.time() + dt = t2 - t1 - self.checkResponse(url, 'POST', expected_code, response) + self.check_response(url, response, expected_code=expected_code) + + if dt > max_query_time: + print( + f'Query time exceeded at {url}: Expected {max_query_time}s, got {dt}s' + ) + + self.assertLessEqual(dt, max_query_time) return response - def delete(self, url, data=None, expected_code=None, format='json', **kwargs): - """Issue a DELETE request.""" - if data is None: - data = {} + def get(self, url, data=None, expected_code=200, **kwargs): + """Issue a GET request.""" + kwargs['data'] = data - response = self.client.delete(url, data=data, format=format, **kwargs) + return self.query(url, self.client.get, expected_code=expected_code, **kwargs) - self.checkResponse(url, 'DELETE', expected_code, response) + def post(self, url, data=None, expected_code=201, **kwargs): + """Issue a POST request.""" + # Default query limit is higher for POST requests, due to extra event processing + kwargs['max_query_count'] = kwargs.get( + 'max_query_count', self.MAX_QUERY_COUNT + 100 + ) - return response + kwargs['data'] = data - def patch(self, url, data, expected_code=None, format='json', **kwargs): - """Issue a PATCH request.""" - response = self.client.patch(url, data=data, format=format, **kwargs) + return self.query(url, self.client.post, expected_code=expected_code, **kwargs) - self.checkResponse(url, 'PATCH', expected_code, response) + def delete(self, url, data=None, expected_code=204, **kwargs): + """Issue a DELETE request.""" + kwargs['data'] = data - return response + return self.query( + url, self.client.delete, expected_code=expected_code, **kwargs + ) - def put(self, url, data, expected_code=None, format='json', **kwargs): - """Issue a PUT request.""" - response = self.client.put(url, data=data, format=format, **kwargs) + def patch(self, url, data, expected_code=200, **kwargs): + """Issue a PATCH request.""" + kwargs['data'] = data - self.checkResponse(url, 'PUT', expected_code, response) + return self.query(url, self.client.patch, expected_code=expected_code, **kwargs) - return response + def put(self, url, data, expected_code=200, **kwargs): + """Issue a PUT request.""" + kwargs['data'] = data + + return self.query(url, self.client.put, expected_code=expected_code, **kwargs) def options(self, url, expected_code=None, **kwargs): """Issue an OPTIONS request.""" - response = self.client.options(url, format='json', **kwargs) - - self.checkResponse(url, 'OPTIONS', expected_code, response) + kwargs['data'] = kwargs.get('data', None) - return response + return self.query( + url, self.client.options, expected_code=expected_code, **kwargs + ) def download_file( - self, url, data, expected_code=None, expected_fn=None, decode=True + self, url, data, expected_code=None, expected_fn=None, decode=True, **kwargs ): """Download a file from the server, and return an in-memory file.""" response = self.client.get(url, data=data, format='json') - self.checkResponse(url, 'DOWNLOAD_FILE', expected_code, response) + self.check_response(url, response, expected_code=expected_code) # Check that the response is of the correct type if not isinstance(response, StreamingHttpResponse): diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 7257cafda332..cd1713126ee8 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -567,7 +567,7 @@ def complete_allocations(self, user): self.allocated_stock.delete() @transaction.atomic - def complete_build(self, user): + def complete_build(self, user, trim_allocated_stock=False): """Mark this build as complete.""" import build.tasks @@ -575,6 +575,9 @@ def complete_build(self, user): if self.incomplete_count > 0: return + if trim_allocated_stock: + self.trim_allocated_stock() + self.completion_date = InvenTree.helpers.current_date() self.completed_by = user self.status = BuildStatus.COMPLETE.value @@ -858,6 +861,10 @@ def delete_output(self, output): def trim_allocated_stock(self): """Called after save to reduce allocated stock if the build order is now overallocated.""" # Only need to worry about untracked stock here + + items_to_save = [] + items_to_delete = [] + for build_line in self.untracked_line_items: reduce_by = build_line.allocated_quantity() - build_line.quantity @@ -875,13 +882,19 @@ def trim_allocated_stock(self): # Easy case - this item can just be reduced. if item.quantity > reduce_by: item.quantity -= reduce_by - item.save() + items_to_save.append(item) break # Harder case, this item needs to be deleted, and any remainder # taken from the next items in the list. reduce_by -= item.quantity - item.delete() + items_to_delete.append(item) + + # Save the updated BuildItem objects + BuildItem.objects.bulk_update(items_to_save, ['quantity']) + + # Delete the remaining BuildItem objects + BuildItem.objects.filter(pk__in=[item.pk for item in items_to_delete]).delete() @property def allocated_stock(self): @@ -978,7 +991,10 @@ def complete_build_output(self, output, user, **kwargs): # List the allocated BuildItem objects for the given output allocated_items = output.items_to_install.all() - if (common.settings.prevent_build_output_complete_on_incompleted_tests() and output.hasRequiredTests() and not output.passedAllRequiredTests()): + required_tests = kwargs.get('required_tests', output.part.getRequiredTests()) + prevent_on_incomplete = kwargs.get('prevent_on_incomplete', common.settings.prevent_build_output_complete_on_incompleted_tests()) + + if (prevent_on_incomplete and not output.passedAllRequiredTests(required_tests=required_tests)): serial = output.serial raise ValidationError( _(f"Build output {serial} has not passed all required tests")) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 85bd510ac0f4..ac19b6ef2c04 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -568,10 +568,13 @@ def save(self): outputs = data.get('outputs', []) + # Cache some calculated values which can be passed to each output + required_tests = outputs[0]['output'].part.getRequiredTests() + prevent_on_incomplete = common.settings.prevent_build_output_complete_on_incompleted_tests() + # Mark the specified build outputs as "complete" with transaction.atomic(): for item in outputs: - output = item['output'] build.complete_build_output( @@ -580,6 +583,8 @@ def save(self): location=location, status=status, notes=notes, + required_tests=required_tests, + prevent_on_incomplete=prevent_on_incomplete, ) @@ -734,10 +739,11 @@ def save(self): build = self.context['build'] data = self.validated_data - if data.get('accept_overallocated', OverallocationChoice.REJECT) == OverallocationChoice.TRIM: - build.trim_allocated_stock() - build.complete_build(request.user) + build.complete_build( + request.user, + trim_allocated_stock=data.get('accept_overallocated', OverallocationChoice.REJECT) == OverallocationChoice.TRIM + ) class BuildUnallocationSerializer(serializers.Serializer): diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 682a17e6598b..1fc1a368ee22 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -223,6 +223,7 @@ def test_complete(self): "status": 50, # Item requires attention }, expected_code=201, + max_query_count=450, # TODO: Try to optimize this ) self.assertEqual(self.build.incomplete_outputs.count(), 0) @@ -992,6 +993,7 @@ def test_overallocated_requires_acceptance(self): 'accept_overallocated': 'accept', }, expected_code=201, + max_query_count=550, # TODO: Come back and refactor this ) self.build.refresh_from_db() @@ -1012,6 +1014,7 @@ def test_overallocated_can_trim(self): 'accept_overallocated': 'trim', }, expected_code=201, + max_query_count=550, # TODO: Come back and refactor this ) self.build.refresh_from_db() diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index d5f18234e8f7..15855b462cea 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -749,6 +749,7 @@ def set_setting(cls, key, value, change_user=None, create=True, **kwargs): attempts=attempts - 1, **kwargs, ) + except (OperationalError, ProgrammingError): logger.warning("Database is locked, cannot set setting '%s'", key) # Likely the DB is locked - not much we can do here diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index cc5ac61270c7..9a7960927699 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -1093,7 +1093,7 @@ def test_refresh_endpoint(self): # Updating via the external exchange may not work every time for _idx in range(5): - self.post(reverse('api-currency-refresh')) + self.post(reverse('api-currency-refresh'), expected_code=200) # There should be some new exchange rate objects now if Rate.objects.all().exists(): diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index fb59f8b44611..a9824442be24 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -1108,7 +1108,7 @@ def test_serial_numbers(self): n = StockItem.objects.count() - self.post(self.url, data, expected_code=201) + self.post(self.url, data, expected_code=201, max_query_count=400) # Check that the expected number of stock items has been created self.assertEqual(n + 11, StockItem.objects.count()) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index faaacfb0a5a9..77396176e32c 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -371,8 +371,6 @@ class Target(IntEnum): params['delete_child_categories'] = '1' response = self.delete(url, params, expected_code=204) - self.assertEqual(response.status_code, 204) - if delete_parts: if i == Target.delete_subcategories_delete_parts: # Check if all parts deleted @@ -685,7 +683,6 @@ def test_get_categories(self): # Request *all* part categories response = self.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 8) # Request top-level part categories only @@ -709,7 +706,6 @@ def test_add_categories(self): url = reverse('api-part-category-list') response = self.post(url, data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) parent = response.data['pk'] @@ -717,7 +713,6 @@ def test_add_categories(self): for animal in ['cat', 'dog', 'zebra']: data = {'name': animal, 'description': 'A sort of animal', 'parent': parent} response = self.post(url, data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['parent'], parent) self.assertEqual(response.data['name'], animal) self.assertEqual(response.data['pathstring'], 'Animals/' + animal) @@ -741,7 +736,6 @@ def test_cat_detail(self): data['parent'] = None data['description'] = 'Changing the description' response = self.patch(url, data) - self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['description'], 'Changing the description') self.assertIsNone(response.data['parent']) @@ -750,13 +744,11 @@ def test_filter_parts(self): url = reverse('api-part-list') data = {'cascade': True} response = self.get(url, data) - self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), Part.objects.count()) # Test filtering parts by category data = {'category': 2} response = self.get(url, data) - self.assertEqual(response.status_code, status.HTTP_200_OK) # There should only be 2 objects in category C self.assertEqual(len(response.data), 2) @@ -897,7 +889,6 @@ def test_include_children(self): response = self.get(url, data) # Now there should be 5 total parts - self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 3) def test_test_templates(self): @@ -906,8 +897,6 @@ def test_test_templates(self): # List ALL items response = self.get(url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 9) # Request for a particular part @@ -921,10 +910,9 @@ def test_test_templates(self): response = self.post( url, data={'part': 10000, 'test_name': 'My very first test', 'required': False}, + expected_code=400, ) - self.assertEqual(response.status_code, 400) - # Try to post a new object (should succeed) response = self.post( url, @@ -936,20 +924,17 @@ def test_test_templates(self): }, ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - # Try to post a new test with the same name (should fail) response = self.post( url, data={'part': 10004, 'test_name': ' newtest', 'description': 'dafsdf'}, + expected_code=400, ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - # Try to post a new test against a non-trackable part (should fail) - response = self.post(url, data={'part': 1, 'test_name': 'A simple test'}) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response = self.post( + url, data={'part': 1, 'test_name': 'A simple test'}, expected_code=400 + ) def test_get_thumbs(self): """Return list of part thumbnails.""" @@ -957,8 +942,6 @@ def test_get_thumbs(self): response = self.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_paginate(self): """Test pagination of the Part list API.""" for n in [1, 5, 10]: @@ -1450,8 +1433,6 @@ def test_part_operations(self): }, ) - self.assertEqual(response.status_code, 201) - pk = response.data['pk'] # Check that a new part has been added @@ -1470,7 +1451,6 @@ def test_part_operations(self): response = self.patch(url, {'name': 'a new better name'}) - self.assertEqual(response.status_code, 200) self.assertEqual(response.data['pk'], pk) self.assertEqual(response.data['name'], 'a new better name') @@ -1486,24 +1466,17 @@ def test_part_operations(self): # 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things response = self.patch(url, {'name': 'a new better name'}) - self.assertEqual(response.status_code, 200) - # Try to remove a tag response = self.patch(url, {'tags': ['tag1']}) - self.assertEqual(response.status_code, 200) self.assertEqual(response.data['tags'], ['tag1']) # Try to remove the part - response = self.delete(url) - - # As the part is 'active' we cannot delete it - self.assertEqual(response.status_code, 400) + response = self.delete(url, expected_code=400) # So, let's make it not active response = self.patch(url, {'active': False}, expected_code=200) response = self.delete(url) - self.assertEqual(response.status_code, 204) # Part count should have reduced self.assertEqual(Part.objects.count(), n) @@ -1522,8 +1495,6 @@ def test_duplicates(self): }, ) - self.assertEqual(response.status_code, 201) - n = Part.objects.count() # Check that we cannot create a duplicate in a different category @@ -1536,10 +1507,9 @@ def test_duplicates(self): 'category': 2, 'revision': 'A', }, + expected_code=400, ) - self.assertEqual(response.status_code, 400) - # Check that only 1 matching part exists parts = Part.objects.filter( name='part', description='description', IPN='IPN-123' @@ -1560,9 +1530,9 @@ def test_duplicates(self): 'category': 2, 'revision': 'B', }, + expected_code=201, ) - self.assertEqual(response.status_code, 201) self.assertEqual(Part.objects.count(), n + 1) # Now, check that we cannot *change* an existing part to conflict @@ -1571,14 +1541,10 @@ def test_duplicates(self): url = reverse('api-part-detail', kwargs={'pk': pk}) # Attempt to alter the revision code - response = self.patch(url, {'revision': 'A'}) - - self.assertEqual(response.status_code, 400) + response = self.patch(url, {'revision': 'A'}, expected_code=400) # But we *can* change it to a unique revision code - response = self.patch(url, {'revision': 'C'}) - - self.assertEqual(response.status_code, 200) + response = self.patch(url, {'revision': 'C'}, expected_code=200) def test_image_upload(self): """Test that we can upload an image to the part API.""" @@ -1608,10 +1574,9 @@ def test_image_upload(self): with open(f'{test_path}.txt', 'rb') as dummy_image: response = self.upload_client.patch( - url, {'image': dummy_image}, format='multipart' + url, {'image': dummy_image}, format='multipart', expected_code=400 ) - self.assertEqual(response.status_code, 400) self.assertIn('Upload a valid image', str(response.data)) # Now try to upload a valid image file, in multiple formats @@ -1623,11 +1588,9 @@ def test_image_upload(self): with open(fn, 'rb') as dummy_image: response = self.upload_client.patch( - url, {'image': dummy_image}, format='multipart' + url, {'image': dummy_image}, format='multipart', expected_code=200 ) - self.assertEqual(response.status_code, 200) - # And now check that the image has been set p = Part.objects.get(pk=pk) self.assertIsNotNone(p.image) @@ -1644,10 +1607,11 @@ def test_existing_image(self): with open(fn, 'rb') as img_file: response = self.upload_client.patch( - reverse('api-part-detail', kwargs={'pk': p.pk}), {'image': img_file} + reverse('api-part-detail', kwargs={'pk': p.pk}), + {'image': img_file}, + expected_code=200, ) - self.assertEqual(response.status_code, 200) image_name = response.data['image'] self.assertTrue(image_name.startswith('/media/part_images/part_image')) @@ -1690,10 +1654,11 @@ def test_update_existing_image(self): # Upload the image to a part with open(fn, 'rb') as img_file: response = self.upload_client.patch( - reverse('api-part-detail', kwargs={'pk': p.pk}), {'image': img_file} + reverse('api-part-detail', kwargs={'pk': p.pk}), + {'image': img_file}, + expected_code=200, ) - self.assertEqual(response.status_code, 200) image_name = response.data['image'] self.assertTrue(image_name.startswith('/media/part_images/part_image')) @@ -1949,8 +1914,6 @@ def get_part_data(self): response = self.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - for part in response.data: if part['pk'] == self.part.pk: return part @@ -2677,8 +2640,7 @@ def test_create_price_breaks(self): InvenTreeSetting.set_setting('PART_ALLOW_DELETE_FROM_ASSEMBLY', True) - response = self.delete(reverse('api-part-detail', kwargs={'pk': 1})) - self.assertEqual(response.status_code, 204) + self.delete(reverse('api-part-detail', kwargs={'pk': 1})) with self.assertRaises(Part.DoesNotExist): p.refresh_from_db() diff --git a/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py b/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py index f69859088455..408c28d35fab 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py +++ b/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py @@ -27,25 +27,19 @@ def setUp(self): def postBarcode(self, url, barcode, expected_code=None): """Post barcode and return results.""" return self.post( - url, - format='json', - data={'barcode': str(barcode)}, - expected_code=expected_code, + url, data={'barcode': str(barcode)}, expected_code=expected_code ) def test_invalid(self): """Test that invalid requests fail.""" # test scan url - self.post(self.scan_url, format='json', data={}, expected_code=400) + self.post(self.scan_url, data={}, expected_code=400) # test wrong assign urls - self.post(self.assign_url, format='json', data={}, expected_code=400) - self.post( - self.assign_url, format='json', data={'barcode': '123'}, expected_code=400 - ) + self.post(self.assign_url, data={}, expected_code=400) + self.post(self.assign_url, data={'barcode': '123'}, expected_code=400) self.post( self.assign_url, - format='json', data={'barcode': '123', 'stockitem': '123'}, expected_code=400, ) @@ -163,7 +157,6 @@ def test_association(self): response = self.post( self.assign_url, - format='json', data={'barcode': barcode_data, 'stockitem': item.pk}, expected_code=200, ) @@ -183,7 +176,6 @@ def test_association(self): # Ensure that the same barcode hash cannot be assigned to a different stock item! response = self.post( self.assign_url, - format='json', data={'barcode': barcode_data, 'stockitem': 521}, expected_code=400, ) diff --git a/src/backend/InvenTree/plugin/base/event/events.py b/src/backend/InvenTree/plugin/base/event/events.py index 8206c72d73fc..27544aea34de 100644 --- a/src/backend/InvenTree/plugin/base/event/events.py +++ b/src/backend/InvenTree/plugin/base/event/events.py @@ -23,10 +23,6 @@ def trigger_event(event, *args, **kwargs): """ from common.models import InvenTreeSetting - if not settings.PLUGINS_ENABLED: - # Do nothing if plugins are not enabled - return # pragma: no cover - if not InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS', False): # Do nothing if plugin events are not enabled return @@ -59,7 +55,9 @@ def register_event(event, *args, **kwargs): logger.debug("Registering triggered event: '%s'", event) # Determine if there are any plugins which are interested in responding - if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'): + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting( + 'ENABLE_PLUGINS_EVENTS', cache=False + ): # Check if the plugin registry needs to be reloaded registry.check_reload() diff --git a/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py b/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py index a6cad702f766..e12cc2a25d8f 100644 --- a/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py @@ -58,10 +58,10 @@ def __init__(self): @classmethod def _activate_mixin(cls, registry, plugins, *args, **kwargs): """Activate schedules from plugins with the ScheduleMixin.""" - logger.debug('Activating plugin tasks') - from common.models import InvenTreeSetting + logger.debug('Activating plugin tasks') + # List of tasks we have activated task_keys = [] diff --git a/src/backend/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py b/src/backend/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py index 4f5a4b405ca1..233d174d644d 100644 --- a/src/backend/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py +++ b/src/backend/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py @@ -19,7 +19,6 @@ def test_assign_errors(self): def test_assert_error(barcode_data): response = self.post( reverse('api-barcode-link'), - format='json', data={'barcode': barcode_data, 'stockitem': 521}, expected_code=400, ) diff --git a/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py b/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py index 49bff6903e4b..dd9e8f50fb76 100644 --- a/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py +++ b/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py @@ -198,7 +198,7 @@ def test_receive(self): result1 = self.post(url, data={'barcode': DIGIKEY_BARCODE}, expected_code=400) - assert result1.data['error'].startswith('No matching purchase order') + self.assertTrue(result1.data['error'].startswith('No matching purchase order')) self.purchase_order1.place_order() @@ -211,8 +211,10 @@ def test_receive(self): result4 = self.post( url, data={'barcode': DIGIKEY_BARCODE[:-1]}, expected_code=400 ) - assert result4.data['error'].startswith( - 'Failed to find pending line item for supplier part' + self.assertTrue( + result4.data['error'].startswith( + 'Failed to find pending line item for supplier part' + ) ) result5 = self.post( @@ -221,38 +223,42 @@ def test_receive(self): expected_code=200, ) stock_item = StockItem.objects.get(pk=result5.data['stockitem']['pk']) - assert stock_item.supplier_part.SKU == '296-LM358BIDDFRCT-ND' - assert stock_item.quantity == 10 - assert stock_item.location is None + self.assertEqual(stock_item.supplier_part.SKU, '296-LM358BIDDFRCT-ND') + self.assertEqual(stock_item.quantity, 10) + self.assertEqual(stock_item.location, None) def test_receive_custom_order_number(self): """Test receiving an item from a barcode with a custom order number.""" url = reverse('api-barcode-po-receive') - result1 = self.post(url, data={'barcode': MOUSER_BARCODE}) - assert 'success' in result1.data + result1 = self.post(url, data={'barcode': MOUSER_BARCODE}, expected_code=200) + self.assertIn('success', result1.data) result2 = self.post( - reverse('api-barcode-scan'), data={'barcode': MOUSER_BARCODE} + reverse('api-barcode-scan'), + data={'barcode': MOUSER_BARCODE}, + expected_code=200, ) stock_item = StockItem.objects.get(pk=result2.data['stockitem']['pk']) - assert stock_item.supplier_part.SKU == '42' - assert stock_item.supplier_part.manufacturer_part.MPN == 'MC34063ADR' - assert stock_item.quantity == 3 - assert stock_item.location is None + self.assertEqual(stock_item.supplier_part.SKU, '42') + self.assertEqual(stock_item.supplier_part.manufacturer_part.MPN, 'MC34063ADR') + self.assertEqual(stock_item.quantity, 3) + self.assertEqual(stock_item.location, None) def test_receive_one_stock_location(self): """Test receiving an item when only one stock location exists.""" stock_location = StockLocation.objects.create(name='Test Location') url = reverse('api-barcode-po-receive') - result1 = self.post(url, data={'barcode': MOUSER_BARCODE}) - assert 'success' in result1.data + result1 = self.post(url, data={'barcode': MOUSER_BARCODE}, expected_code=200) + self.assertIn('success', result1.data) result2 = self.post( - reverse('api-barcode-scan'), data={'barcode': MOUSER_BARCODE} + reverse('api-barcode-scan'), + data={'barcode': MOUSER_BARCODE}, + expected_code=200, ) stock_item = StockItem.objects.get(pk=result2.data['stockitem']['pk']) - assert stock_item.location == stock_location + self.assertEqual(stock_item.location, stock_location) def test_receive_default_line_item_location(self): """Test receiving an item into the default line_item location.""" @@ -264,14 +270,16 @@ def test_receive_default_line_item_location(self): line_item.save() url = reverse('api-barcode-po-receive') - result1 = self.post(url, data={'barcode': MOUSER_BARCODE}) - assert 'success' in result1.data + result1 = self.post(url, data={'barcode': MOUSER_BARCODE}, expected_code=200) + self.assertIn('success', result1.data) result2 = self.post( - reverse('api-barcode-scan'), data={'barcode': MOUSER_BARCODE} + reverse('api-barcode-scan'), + data={'barcode': MOUSER_BARCODE}, + expected_code=200, ) stock_item = StockItem.objects.get(pk=result2.data['stockitem']['pk']) - assert stock_item.location == stock_location2 + self.assertEqual(stock_item.location, stock_location2) def test_receive_default_part_location(self): """Test receiving an item into the default part location.""" @@ -283,14 +291,16 @@ def test_receive_default_part_location(self): part.save() url = reverse('api-barcode-po-receive') - result1 = self.post(url, data={'barcode': MOUSER_BARCODE}) - assert 'success' in result1.data + result1 = self.post(url, data={'barcode': MOUSER_BARCODE}, expected_code=200) + self.assertIn('success', result1.data) result2 = self.post( - reverse('api-barcode-scan'), data={'barcode': MOUSER_BARCODE} + reverse('api-barcode-scan'), + data={'barcode': MOUSER_BARCODE}, + expected_code=200, ) stock_item = StockItem.objects.get(pk=result2.data['stockitem']['pk']) - assert stock_item.location == stock_location2 + self.assertEqual(stock_item.location, stock_location2) def test_receive_specific_order_and_location(self): """Test receiving an item from a specific order into a specific location.""" @@ -306,12 +316,15 @@ def test_receive_specific_order_and_location(self): 'purchase_order': self.purchase_order2.pk, 'location': stock_location2.pk, }, + expected_code=200, ) - assert 'success' in result1.data + self.assertIn('success', result1.data) - result2 = self.post(reverse('api-barcode-scan'), data={'barcode': barcode}) + result2 = self.post( + reverse('api-barcode-scan'), data={'barcode': barcode}, expected_code=200 + ) stock_item = StockItem.objects.get(pk=result2.data['stockitem']['pk']) - assert stock_item.location == stock_location2 + self.assertEqual(stock_item.location, stock_location2) def test_receive_missing_quantity(self): """Test receiving an with missing quantity information.""" @@ -319,8 +332,8 @@ def test_receive_missing_quantity(self): barcode = MOUSER_BARCODE.replace('\x1dQ3', '') response = self.post(url, data={'barcode': barcode}, expected_code=200) - assert 'lineitem' in response.data - assert 'quantity' not in response.data['lineitem'] + self.assertIn('lineitem', response.data) + self.assertNotIn('quantity', response.data['lineitem']) DIGIKEY_BARCODE = ( diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index 15f01bd127f1..6349d8ec68a1 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -758,6 +758,16 @@ def update_plugin_hash(self): # Some other exception, we want to know about it logger.exception('Failed to update plugin registry hash: %s', str(exc)) + def plugin_settings_keys(self): + """A list of keys which are used to store plugin settings.""" + return [ + 'ENABLE_PLUGINS_URL', + 'ENABLE_PLUGINS_NAVIGATION', + 'ENABLE_PLUGINS_APP', + 'ENABLE_PLUGINS_SCHEDULE', + 'ENABLE_PLUGINS_EVENTS', + ] + def calculate_plugin_hash(self): """Calculate a 'hash' value for the current registry. @@ -777,31 +787,24 @@ def calculate_plugin_hash(self): data.update(str(plug.version).encode()) data.update(str(plug.is_active()).encode()) - # Also hash for all config settings which define plugin behavior - keys = [ - 'ENABLE_PLUGINS_URL', - 'ENABLE_PLUGINS_NAVIGATION', - 'ENABLE_PLUGINS_APP', - 'ENABLE_PLUGINS_SCHEDULE', - 'ENABLE_PLUGINS_EVENTS', - ] - - for k in keys: + for k in self.plugin_settings_keys(): try: - data.update( - str( - InvenTreeSetting.get_setting( - k, False, cache=False, create=False - ) - ).encode() - ) + val = InvenTreeSetting.get_setting(k, False, cache=False, create=False) + msg = f'{k}-{val}' + + data.update(msg.encode()) except Exception: pass return str(data.hexdigest()) def check_reload(self): - """Determine if the registry needs to be reloaded.""" + """Determine if the registry needs to be reloaded. + + - If a "request" object is available, then we can cache the result and attach it. + - The assumption is that plugins will not change during a single request. + + """ from common.models import InvenTreeSetting if settings.TESTING: diff --git a/src/backend/InvenTree/plugin/templatetags/plugin_extras.py b/src/backend/InvenTree/plugin/templatetags/plugin_extras.py index 0483b1588e22..26612b76badd 100644 --- a/src/backend/InvenTree/plugin/templatetags/plugin_extras.py +++ b/src/backend/InvenTree/plugin/templatetags/plugin_extras.py @@ -6,7 +6,7 @@ from common.models import InvenTreeSetting from common.notifications import storage -from plugin import registry +from plugin.registry import registry register = template.Library() diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index b6746bec9946..ed4ae7439b00 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -35,18 +35,25 @@ def test_plugin_install(self): 'packagename': 'invalid_package_name-asdads-asfd-asdf-asdf-asdf', }, expected_code=400, + max_query_time=20, ) # valid - Pypi data = self.post( - url, {'confirm': True, 'packagename': self.PKG_NAME}, expected_code=201 + url, + {'confirm': True, 'packagename': self.PKG_NAME}, + expected_code=201, + max_query_time=20, ).data self.assertEqual(data['success'], 'Installed plugin successfully') # valid - github url data = self.post( - url, {'confirm': True, 'url': self.PKG_URL}, expected_code=201 + url, + {'confirm': True, 'url': self.PKG_URL}, + expected_code=201, + max_query_time=20, ).data self.assertEqual(data['success'], 'Installed plugin successfully') @@ -56,6 +63,7 @@ def test_plugin_install(self): url, {'confirm': True, 'url': self.PKG_URL, 'packagename': self.PKG_NAME}, expected_code=201, + max_query_time=20, ).data self.assertEqual(data['success'], 'Installed plugin successfully') diff --git a/src/backend/InvenTree/plugin/urls.py b/src/backend/InvenTree/plugin/urls.py index a551d9d9e5f3..ac454e588e8a 100644 --- a/src/backend/InvenTree/plugin/urls.py +++ b/src/backend/InvenTree/plugin/urls.py @@ -9,15 +9,12 @@ def get_plugin_urls(): """Returns a urlpattern that can be integrated into the global urls.""" from common.models import InvenTreeSetting - from plugin import registry + from plugin.registry import registry urls = [] - # Only allow custom routing if the setting is enabled if ( - InvenTreeSetting.get_setting( - 'ENABLE_PLUGINS_URL', False, create=False, cache=False - ) + InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL', False) or settings.PLUGIN_TESTING_SETUP ): for plugin in registry.plugins.values(): diff --git a/src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py b/src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py index 036fc62faa1b..838a0ae552b2 100644 --- a/src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py +++ b/src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py @@ -6,6 +6,8 @@ from django.core.files.base import ContentFile from django.core.files.storage import default_storage +import InvenTree.ready + def label_model_map(): """Map legacy label template models to model_type values.""" @@ -43,7 +45,8 @@ def convert_legacy_labels(table_name, model_name, template_model): cursor.execute(query) except Exception: # Table likely does not exist - print(f"Legacy label table {table_name} not found - skipping migration") + if not InvenTree.ready.isInTestMode(): + print(f"Legacy label table {table_name} not found - skipping migration") return 0 rows = cursor.fetchall() diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index eeec4f80ea55..cdf115b7a27d 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -446,6 +446,8 @@ def run_print_test(self, qs, model_type, label: bool = True): 'items': [item.pk for item in qs], }, expected_code=201, + max_query_time=15, + max_query_count=1000, # TODO: Should look into this ) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index f1787fb34f3d..e4eb90192e23 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2160,7 +2160,7 @@ def testResultList(self, **kwargs): """Return a list of test-result objects for this StockItem.""" return list(self.testResultMap(**kwargs).values()) - def requiredTestStatus(self): + def requiredTestStatus(self, required_tests=None): """Return the status of the tests required for this StockItem. Return: @@ -2170,15 +2170,17 @@ def requiredTestStatus(self): - failed: Number of tests that have failed """ # All the tests required by the part object - required = self.part.getRequiredTests() + + if required_tests is None: + required_tests = self.part.getRequiredTests() results = self.testResultMap() - total = len(required) + total = len(required_tests) passed = 0 failed = 0 - for test in required: + for test in required_tests: key = InvenTree.helpers.generateTestKey(test.test_name) if key in results: @@ -2200,9 +2202,9 @@ def hasRequiredTests(self): """Return True if there are any 'required tests' associated with this StockItem.""" return self.required_test_count > 0 - def passedAllRequiredTests(self): + def passedAllRequiredTests(self, required_tests=None): """Returns True if this StockItem has passed all required tests.""" - status = self.requiredTestStatus() + status = self.requiredTestStatus(required_tests=required_tests) return status['passed'] >= status['total'] diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 593f0e0bc92a..42c29c4abff7 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -1304,10 +1304,13 @@ def test_return_from_customer(self): self.assertIn('This field is required', str(response.data['location'])) + # TODO: Return to this and work out why it is taking so long + # Ref: https://github.com/inventree/InvenTree/pull/7157 response = self.post( url, {'location': '1', 'notes': 'Returned from this customer for testing'}, expected_code=201, + max_query_time=5.0, ) item.refresh_from_db() @@ -1417,7 +1420,7 @@ def test_action(self): data = {} # POST with a valid action - response = self.post(url, data) + response = self.post(url, data, expected_code=400) self.assertIn('This field is required', str(response.data['items'])) @@ -1452,7 +1455,7 @@ def test_action(self): # POST with an invalid quantity value data['items'] = [{'pk': 1234, 'quantity': '10x0d'}] - response = self.post(url, data) + response = self.post(url, data, expected_code=400) self.assertContains( response, 'A valid number is required', @@ -1461,7 +1464,8 @@ def test_action(self): data['items'] = [{'pk': 1234, 'quantity': '-1.234'}] - response = self.post(url, data) + response = self.post(url, data, expected_code=400) + self.assertContains( response, 'Ensure this value is greater than or equal to 0', diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index 7c4ee8434957..a544bdec10ba 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -613,9 +613,9 @@ def get_permission_object(permission_string): content_type=content_type, codename=perm ) except ContentType.DoesNotExist: # pragma: no cover - logger.warning( - "Error: Could not find permission matching '%s'", permission_string - ) + # logger.warning( + # "Error: Could not find permission matching '%s'", permission_string + # ) permission = None return permission