Query count test (#7157)

* Enforce query count middleware for testing

* Cache "DISPLAY_FULL_NAMES" setting

- Much better API performance

* Update unit_test.py

- Add default check for max query count

* Rework unit_test.py

- x-django-query-count header does not get passed through testing framework

* Adjust middleware settings

* Fix debug print

* Refactoring unit_test.py

* Adjust defaults

* Increase default query threshold

- We can work to reduce this further

* Remove outdated comment

* Install django-middleware-global-request

- Makes the request object globally available
- Cache plugin information against it

* Cache "plugins_checked" against global request

- reduce number of times we need to recalculate plugin data

* Cache plugin information to the request

- Prevent duplicate reloads if not required

* Simplify caching of settings

* Revert line

* Allow higher default counts for POST requests

* Remove global request middleware

- Better to implement proper global cache

* increase CI query thresholds

* Fix typo

* API updates

* Unit test updates

* Increase default MAX_QUERY_TIME

* Increase max query time for plugin functions

* Cleanup barcode unit tests

* Fix part test

* Update more tests

* Further unit test updates

* Updates for unit test code

* Fix for unit testing framework

* Fix

* Reduce default query time

* Increase time allowance
This commit is contained in:
Oliver 2024-05-29 13:18:33 +10:00 committed by GitHub
parent c196511327
commit fb193cae3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 261 additions and 223 deletions

View File

@ -273,13 +273,14 @@ if DEBUG and get_boolean_setting(
'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': [],

View File

@ -4,6 +4,7 @@ import csv
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 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
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 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
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 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
actions = response.data.get('actions', {})
return actions
def get(self, url, data=None, expected_code=200, format='json', **kwargs):
def query(self, url, method, data=None, **kwargs):
"""Perform a generic API query."""
if data is None:
data = {}
expected_code = kwargs.pop('expected_code', None)
kwargs['format'] = kwargs.get('format', 'json')
max_queries = kwargs.get('max_query_count', self.MAX_QUERY_COUNT)
max_query_time = kwargs.get('max_query_time', self.MAX_QUERY_TIME)
t1 = time.time()
with self.assertNumQueriesLessThan(max_queries, url=url):
response = method(url, data, **kwargs)
t2 = time.time()
dt = t2 - t1
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 get(self, url, data=None, expected_code=200, **kwargs):
"""Issue a GET request."""
# Set default - see B006
if data is None:
data = {}
kwargs['data'] = data
response = self.client.get(url, data, format=format, **kwargs)
return self.query(url, self.client.get, expected_code=expected_code, **kwargs)
self.checkResponse(url, 'GET', expected_code, response)
return response
def post(self, url, data=None, expected_code=None, format='json', **kwargs):
def post(self, url, data=None, expected_code=201, **kwargs):
"""Issue a POST request."""
# Set default value - see B006
if data is None:
data = {}
# 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
)
response = self.client.post(url, data=data, format=format, **kwargs)
kwargs['data'] = data
self.checkResponse(url, 'POST', expected_code, response)
return self.query(url, self.client.post, expected_code=expected_code, **kwargs)
return response
def delete(self, url, data=None, expected_code=None, format='json', **kwargs):
def delete(self, url, data=None, expected_code=204, **kwargs):
"""Issue a DELETE request."""
if data is None:
data = {}
kwargs['data'] = data
response = self.client.delete(url, data=data, format=format, **kwargs)
return self.query(
url, self.client.delete, expected_code=expected_code, **kwargs
)
self.checkResponse(url, 'DELETE', expected_code, response)
return response
def patch(self, url, data, expected_code=None, format='json', **kwargs):
def patch(self, url, data, expected_code=200, **kwargs):
"""Issue a PATCH request."""
response = self.client.patch(url, data=data, format=format, **kwargs)
kwargs['data'] = data
self.checkResponse(url, 'PATCH', expected_code, response)
return self.query(url, self.client.patch, expected_code=expected_code, **kwargs)
return response
def put(self, url, data, expected_code=None, format='json', **kwargs):
def put(self, url, data, expected_code=200, **kwargs):
"""Issue a PUT request."""
response = self.client.put(url, data=data, format=format, **kwargs)
kwargs['data'] = data
self.checkResponse(url, 'PUT', expected_code, response)
return response
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)
kwargs['data'] = kwargs.get('data', None)
self.checkResponse(url, 'OPTIONS', expected_code, response)
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):

View File

@ -567,7 +567,7 @@ class Build(
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 @@ class Build(
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 @@ class Build(
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 @@ class Build(
# 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 @@ class Build(
# 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"))

View File

@ -568,10 +568,13 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
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 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
location=location,
status=status,
notes=notes,
required_tests=required_tests,
prevent_on_incomplete=prevent_on_incomplete,
)
@ -734,10 +739,11 @@ class BuildCompleteSerializer(serializers.Serializer):
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):

View File

@ -223,6 +223,7 @@ class BuildTest(BuildAPITest):
"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 @@ class BuildOverallocationTest(BuildAPITest):
'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 @@ class BuildOverallocationTest(BuildAPITest):
'accept_overallocated': 'trim',
},
expected_code=201,
max_query_count=550, # TODO: Come back and refactor this
)
self.build.refresh_from_db()

View File

@ -749,6 +749,7 @@ class BaseInvenTreeSetting(models.Model):
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

View File

@ -1093,7 +1093,7 @@ class CurrencyAPITests(InvenTreeAPITestCase):
# 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():

View File

@ -1108,7 +1108,7 @@ class PurchaseOrderReceiveTest(OrderTest):
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())

View File

@ -371,8 +371,6 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
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 @@ class PartAPITest(PartAPITestBase):
# 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 @@ class PartAPITest(PartAPITestBase):
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 @@ class PartAPITest(PartAPITestBase):
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 @@ class PartAPITest(PartAPITestBase):
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 @@ class PartAPITest(PartAPITestBase):
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 @@ class PartAPITest(PartAPITestBase):
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 @@ class PartAPITest(PartAPITestBase):
# 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 @@ class PartAPITest(PartAPITestBase):
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 @@ class PartAPITest(PartAPITestBase):
},
)
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 @@ class PartAPITest(PartAPITestBase):
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 @@ class PartDetailTests(PartAPITestBase):
},
)
self.assertEqual(response.status_code, 201)
pk = response.data['pk']
# Check that a new part has been added
@ -1470,7 +1451,6 @@ class PartDetailTests(PartAPITestBase):
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 @@ class PartDetailTests(PartAPITestBase):
# 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 @@ class PartDetailTests(PartAPITestBase):
},
)
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 @@ class PartDetailTests(PartAPITestBase):
'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 @@ class PartDetailTests(PartAPITestBase):
'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 @@ class PartDetailTests(PartAPITestBase):
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 @@ class PartDetailTests(PartAPITestBase):
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 @@ class PartDetailTests(PartAPITestBase):
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 @@ class PartDetailTests(PartAPITestBase):
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 @@ class PartDetailTests(PartAPITestBase):
# 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 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
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 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase):
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()

View File

@ -27,25 +27,19 @@ class BarcodeAPITest(InvenTreeAPITestCase):
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 @@ class BarcodeAPITest(InvenTreeAPITestCase):
response = self.post(
self.assign_url,
format='json',
data={'barcode': barcode_data, 'stockitem': item.pk},
expected_code=200,
)
@ -183,7 +176,6 @@ class BarcodeAPITest(InvenTreeAPITestCase):
# 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,
)

View File

@ -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()

View File

@ -58,10 +58,10 @@ class ScheduleMixin:
@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 = []

View File

@ -19,7 +19,6 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
def test_assert_error(barcode_data):
response = self.post(
reverse('api-barcode-link'),
format='json',
data={'barcode': barcode_data, 'stockitem': 521},
expected_code=400,
)

View File

@ -198,7 +198,7 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
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 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
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 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
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 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
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 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
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 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
'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 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
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 = (

View File

@ -758,6 +758,16 @@ class PluginsRegistry:
# 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 @@ class PluginsRegistry:
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:

View File

@ -6,7 +6,7 @@ from django.urls import reverse
from common.models import InvenTreeSetting
from common.notifications import storage
from plugin import registry
from plugin.registry import registry
register = template.Library()

View File

@ -35,18 +35,25 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
'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 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
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')

View File

@ -9,15 +9,12 @@ PLUGIN_BASE = 'plugin' # Constant for links
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():

View File

@ -6,6 +6,8 @@ from django.db import connection, migrations
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()

View File

@ -446,6 +446,8 @@ class PrintTestMixins:
'items': [item.pk for item in qs],
},
expected_code=201,
max_query_time=15,
max_query_count=1000, # TODO: Should look into this
)

View File

@ -2160,7 +2160,7 @@ class StockItem(
"""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 @@ class StockItem(
- 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 @@ class StockItem(
"""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']

View File

@ -1304,10 +1304,13 @@ class StockItemTest(StockAPITestCase):
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 @@ class StocktakeTest(StockAPITestCase):
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 @@ class StocktakeTest(StockAPITestCase):
# 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 @@ class StocktakeTest(StockAPITestCase):
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',

View File

@ -613,9 +613,9 @@ def update_group_roles(group, debug=False):
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