diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index a53fd567ab..a750c393e6 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import re import json +import warnings import requests import logging @@ -11,6 +12,8 @@ from django.utils import timezone from django.core.exceptions import AppRegistryNotReady from django.db.utils import OperationalError, ProgrammingError +from django.core import mail as django_mail +from django.conf import settings logger = logging.getLogger("inventree") @@ -52,6 +55,15 @@ def schedule_task(taskname, **kwargs): pass +def raise_warning(msg): + """Log and raise a warning""" + logger.warning(msg) + + # If testing is running raise a warning that can be asserted + if settings.TESTING: + warnings.warn(msg) + + def offload_task(taskname, *args, force_sync=False, **kwargs): """ Create an AsyncTask if workers are running. @@ -67,28 +79,38 @@ def offload_task(taskname, *args, force_sync=False, **kwargs): import importlib from InvenTree.status import is_worker_running + except AppRegistryNotReady: # pragma: no cover + logger.warning(f"Could not offload task '{taskname}' - app registry not ready") + return + except (OperationalError, ProgrammingError): # pragma: no cover + raise_warning(f"Could not offload task '{taskname}' - database not ready") - if is_worker_running() and not force_sync: # pragma: no cover - # Running as asynchronous task - try: - task = AsyncTask(taskname, *args, **kwargs) - task.run() - except ImportError: - logger.warning(f"WARNING: '{taskname}' not started - Function not found") + if is_worker_running() and not force_sync: # pragma: no cover + # Running as asynchronous task + try: + task = AsyncTask(taskname, *args, **kwargs) + task.run() + except ImportError: + raise_warning(f"WARNING: '{taskname}' not started - Function not found") + else: + + if callable(taskname): + # function was passed - use that + _func = taskname else: # Split path try: app, mod, func = taskname.split('.') app_mod = app + '.' + mod except ValueError: - logger.warning(f"WARNING: '{taskname}' not started - Malformed function path") + raise_warning(f"WARNING: '{taskname}' not started - Malformed function path") return # Import module from app try: _mod = importlib.import_module(app_mod) except ModuleNotFoundError: - logger.warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'") + raise_warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'") return # Retrieve function @@ -102,17 +124,11 @@ def offload_task(taskname, *args, force_sync=False, **kwargs): if not _func: _func = eval(func) # pragma: no cover except NameError: - logger.warning(f"WARNING: '{taskname}' not started - No function named '{func}'") + raise_warning(f"WARNING: '{taskname}' not started - No function named '{func}'") return - # Workers are not running: run it as synchronous task - _func(*args, **kwargs) - - except AppRegistryNotReady: # pragma: no cover - logger.warning(f"Could not offload task '{taskname}' - app registry not ready") - return - except (OperationalError, ProgrammingError): # pragma: no cover - logger.warning(f"Could not offload task '{taskname}' - database not ready") + # Workers are not running: run it as synchronous task + _func(*args, **kwargs) def heartbeat(): @@ -205,25 +221,25 @@ def check_for_updates(): response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest') if response.status_code != 200: - raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') + raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover data = json.loads(response.text) tag = data.get('tag_name', None) if not tag: - raise ValueError("'tag_name' missing from GitHub response") + raise ValueError("'tag_name' missing from GitHub response") # pragma: no cover match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag) - if len(match.groups()) != 3: + if len(match.groups()) != 3: # pragma: no cover logger.warning(f"Version '{tag}' did not match expected pattern") return latest_version = [int(x) for x in match.groups()] if len(latest_version) != 3: - raise ValueError(f"Version '{tag}' is not correct format") + raise ValueError(f"Version '{tag}' is not correct format") # pragma: no cover logger.info(f"Latest InvenTree version: '{tag}'") @@ -288,7 +304,7 @@ def send_email(subject, body, recipients, from_email=None, html_message=None): recipients = [recipients] offload_task( - 'django.core.mail.send_mail', + django_mail.send_mail, subject, body, from_email, diff --git a/InvenTree/InvenTree/test_tasks.py b/InvenTree/InvenTree/test_tasks.py index e9c9d9f01c..c8497e2241 100644 --- a/InvenTree/InvenTree/test_tasks.py +++ b/InvenTree/InvenTree/test_tasks.py @@ -2,10 +2,20 @@ Unit tests for task management """ +from datetime import timedelta + +from django.utils import timezone from django.test import TestCase from django_q.models import Schedule +from error_report.models import Error + import InvenTree.tasks +from common.models import InvenTreeSetting + + +threshold = timezone.now() - timedelta(days=30) +threshold_low = threshold - timedelta(days=1) class ScheduledTaskTests(TestCase): @@ -41,3 +51,79 @@ class ScheduledTaskTests(TestCase): # But the 'minutes' should have been updated t = Schedule.objects.get(func=task) self.assertEqual(t.minutes, 5) + + +def get_result(): + """Demo function for test_offloading""" + return 'abc' + + +class InvenTreeTaskTests(TestCase): + """Unit tests for tasks""" + + def test_offloading(self): + """Test task offloading""" + + # Run with function ref + InvenTree.tasks.offload_task(get_result) + + # Run with string ref + InvenTree.tasks.offload_task('InvenTree.test_tasks.get_result') + + # Error runs + # Malformed taskname + with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree' not started - Malformed function path"): + InvenTree.tasks.offload_task('InvenTree') + + # Non exsistent app + with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTreeABC.test_tasks.doesnotmatter' not started - No module named 'InvenTreeABC.test_tasks'"): + InvenTree.tasks.offload_task('InvenTreeABC.test_tasks.doesnotmatter') + + # Non exsistent function + with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree.test_tasks.doesnotexsist' not started - No function named 'doesnotexsist'"): + InvenTree.tasks.offload_task('InvenTree.test_tasks.doesnotexsist') + + def test_task_hearbeat(self): + """Test the task heartbeat""" + InvenTree.tasks.offload_task(InvenTree.tasks.heartbeat) + + def test_task_delete_successful_tasks(self): + """Test the task delete_successful_tasks""" + from django_q.models import Success + + Success.objects.create(name='abc', func='abc', stopped=threshold, started=threshold_low) + InvenTree.tasks.offload_task(InvenTree.tasks.delete_successful_tasks) + results = Success.objects.filter(started__lte=threshold) + self.assertEqual(len(results), 0) + + def test_task_delete_old_error_logs(self): + """Test the task delete_old_error_logs""" + + # Create error + error_obj = Error.objects.create() + error_obj.when = threshold_low + error_obj.save() + + # Check that it is not empty + errors = Error.objects.filter(when__lte=threshold,) + self.assertNotEqual(len(errors), 0) + + # Run action + InvenTree.tasks.offload_task(InvenTree.tasks.delete_old_error_logs) + + # Check that it is empty again + errors = Error.objects.filter(when__lte=threshold,) + self.assertEqual(len(errors), 0) + + def test_task_check_for_updates(self): + """Test the task check_for_updates""" + # Check that setting should be empty + self.assertEqual(InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION'), '') + + # Get new version + InvenTree.tasks.offload_task(InvenTree.tasks.check_for_updates) + + # Check that setting is not empty + response = InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION') + self.assertNotEqual(response, '') + self.assertTrue(bool(response)) diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index a3541e2e0d..5be5fa3519 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -795,13 +795,9 @@ class CurrencyRefreshView(RedirectView): On a POST request we will attempt to refresh the exchange rates """ - from InvenTree.tasks import offload_task + from InvenTree.tasks import offload_task, update_exchange_rates - # Define associated task from InvenTree.tasks list of methods - taskname = 'InvenTree.tasks.update_exchange_rates' - - # Run it - offload_task(taskname, force_sync=True) + offload_task(update_exchange_rates, force_sync=True) return redirect(reverse_lazy('settings')) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index be1403c27d..0f539dc158 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -1139,12 +1139,13 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs): """ Callback function to be executed after a Build instance is saved """ + from . import tasks as build_tasks if created: # A new Build has just been created # Run checks on required parts - InvenTree.tasks.offload_task('build.tasks.check_build_stock', instance) + InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance) class BuildOrderAttachment(InvenTreeAttachment): diff --git a/InvenTree/common/test_tasks.py b/InvenTree/common/test_tasks.py index 3f85316c41..c28a1d19fe 100644 --- a/InvenTree/common/test_tasks.py +++ b/InvenTree/common/test_tasks.py @@ -2,6 +2,7 @@ from django.test import TestCase from common.models import NotificationEntry +from . import tasks as common_tasks from InvenTree.tasks import offload_task @@ -14,4 +15,4 @@ class TaskTest(TestCase): # check empty run self.assertEqual(NotificationEntry.objects.all().count(), 0) - offload_task('common.tasks.delete_old_notifications',) + offload_task(common_tasks.delete_old_notifications,) diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index cb4b939157..34ced6a3cd 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -24,6 +24,7 @@ from plugin.registry import registry from stock.models import StockItem, StockLocation from part.models import Part +from plugin.base.label import label as plugin_label from .models import StockItemLabel, StockLocationLabel, PartLabel from .serializers import StockItemLabelSerializer, StockLocationLabelSerializer, PartLabelSerializer @@ -156,7 +157,7 @@ class LabelPrintMixin: # Offload a background task to print the provided label offload_task( - 'plugin.base.label.label.print_label', + plugin_label.print_label, plugin.plugin_slug(), image, label_instance=label_instance, diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 374cf92626..fa0f1816f8 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -59,7 +59,6 @@ from order import models as OrderModels from company.models import SupplierPart import part.settings as part_settings from stock import models as StockModels - from plugin.models import MetadataMixin @@ -2291,12 +2290,13 @@ def after_save_part(sender, instance: Part, created, **kwargs): """ Function to be executed after a Part is saved """ + from part import tasks as part_tasks if not created and not InvenTree.ready.isImportingData(): # Check part stock only if we are *updating* the part (not creating it) # Run this check in the background - InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance) + InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance) class PartAttachment(InvenTreeAttachment): diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index b158d26ad3..d0c91ac79d 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -49,6 +49,6 @@ def notify_low_stock_if_required(part: part.models.Part): for p in parts: if p.is_part_low_on_stock(): InvenTree.tasks.offload_task( - 'part.tasks.notify_low_stock', + notify_low_stock, p ) diff --git a/InvenTree/plugin/base/event/events.py b/InvenTree/plugin/base/event/events.py index 4b9c44521d..4dea7525b3 100644 --- a/InvenTree/plugin/base/event/events.py +++ b/InvenTree/plugin/base/event/events.py @@ -37,7 +37,7 @@ def trigger_event(event, *args, **kwargs): logger.debug(f"Event triggered: '{event}'") offload_task( - 'plugin.events.register_event', + register_event, event, *args, **kwargs @@ -72,7 +72,7 @@ def register_event(event, *args, **kwargs): # Offload a separate task for each plugin offload_task( - 'plugin.events.process_event', + process_event, slug, event, *args, diff --git a/InvenTree/plugin/base/locate/api.py b/InvenTree/plugin/base/locate/api.py index 30c6d749a9..020951b283 100644 --- a/InvenTree/plugin/base/locate/api.py +++ b/InvenTree/plugin/base/locate/api.py @@ -56,7 +56,7 @@ class LocatePluginView(APIView): try: StockItem.objects.get(pk=item_pk) - offload_task('plugin.registry.call_function', plugin, 'locate_stock_item', item_pk) + offload_task(registry.call_function, plugin, 'locate_stock_item', item_pk) data['item'] = item_pk @@ -69,7 +69,7 @@ class LocatePluginView(APIView): try: StockLocation.objects.get(pk=location_pk) - offload_task('plugin.registry.call_function', plugin, 'locate_stock_location', location_pk) + offload_task(registry.call_function, plugin, 'locate_stock_location', location_pk) data['location'] = location_pk diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index da7f58abb5..6cd0469b75 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -2020,10 +2020,11 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs): """ Function to be executed after a StockItem object is deleted """ + from part import tasks as part_tasks if not InvenTree.ready.isImportingData(): # Run this check in the background - InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part) + InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part) @receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log') @@ -2031,10 +2032,11 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs): """ Hook function to be executed after StockItem object is saved/updated """ + from part import tasks as part_tasks if not InvenTree.ready.isImportingData(): # Run this check in the background - InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part) + InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part) class StockItemAttachment(InvenTreeAttachment):