diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 16d0be035a..dee0eb2e8b 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -571,7 +571,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): super().save() if self.requires_restart(): - InvenTreeSetting.set_setting('SERVER_REQUIRES_RESTART', True, None) + InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None) """ Dict of all global settings values: @@ -978,6 +978,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, 'requires_restart': True, }, + 'ENABLE_PLUGINS_SCHEDULE': { + 'name': _('Enable schedule integration'), + 'description': _('Enable plugins to run scheduled tasks'), + 'default': False, + 'validator': bool, + 'requires_restart': True, + } } class Meta: diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index c5d2411e4d..c6198ed7a1 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -2,6 +2,8 @@ Plugin mixin classes """ +import logging + from django.conf.urls import url, include from django.db.utils import OperationalError, ProgrammingError @@ -9,6 +11,9 @@ from plugin.models import PluginConfig, PluginSetting from plugin.urls import PLUGIN_BASE +logger = logging.getLogger('inventree') + + class SettingsMixin: """ Mixin that enables global settings for the plugin @@ -53,6 +58,128 @@ class SettingsMixin: PluginSetting.set_setting(key, value, user, plugin=plugin) +class ScheduleMixin: + """ + Mixin that provides support for scheduled tasks. + + Implementing classes must provide a dict object called SCHEDULED_TASKS, + which provides information on the tasks to be scheduled. + + SCHEDULED_TASKS = { + # Name of the task (will be prepended with the plugin name) + 'test_server': { + 'func': 'myplugin.tasks.test_server', # Python function to call (no arguments!) + 'schedule': "I", # Schedule type (see django_q.Schedule) + 'minutes': 30, # Number of minutes (only if schedule type = Minutes) + 'repeats': 5, # Number of repeats (leave blank for 'forever') + } + } + + Note: 'schedule' parameter must be one of ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] + """ + + ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] + + SCHEDULED_TASKS = {} + + class MixinMeta: + MIXIN_NAME = 'Schedule' + + def __init__(self): + super().__init__() + self.add_mixin('schedule', 'has_scheduled_tasks', __class__) + self.scheduled_tasks = getattr(self, 'SCHEDULED_TASKS', {}) + + self.validate_scheduled_tasks() + + @property + def has_scheduled_tasks(self): + return bool(self.scheduled_tasks) + + def validate_scheduled_tasks(self): + """ + Check that the provided scheduled tasks are valid + """ + + if not self.has_scheduled_tasks: + raise ValueError("SCHEDULED_TASKS not defined") + + for key, task in self.scheduled_tasks.items(): + + if 'func' not in task: + raise ValueError(f"Task '{key}' is missing 'func' parameter") + + if 'schedule' not in task: + raise ValueError(f"Task '{key}' is missing 'schedule' parameter") + + schedule = task['schedule'].upper().strip() + + if schedule not in self.ALLOWABLE_SCHEDULE_TYPES: + raise ValueError(f"Task '{key}': Schedule '{schedule}' is not a valid option") + + # If 'minutes' is selected, it must be provided! + if schedule == 'I' and 'minutes' not in task: + raise ValueError(f"Task '{key}' is missing 'minutes' parameter") + + def get_task_name(self, key): + # Generate a 'unique' task name + slug = self.plugin_slug() + return f"plugin.{slug}.{key}" + + def get_task_names(self): + # Returns a list of all task names associated with this plugin instance + return [self.get_task_name(key) for key in self.scheduled_tasks.keys()] + + def register_tasks(self): + """ + Register the tasks with the database + """ + + try: + from django_q.models import Schedule + + for key, task in self.scheduled_tasks.items(): + + task_name = self.get_task_name(key) + + # If a matching scheduled task does not exist, create it! + if not Schedule.objects.filter(name=task_name).exists(): + + logger.info(f"Adding scheduled task '{task_name}'") + + Schedule.objects.create( + name=task_name, + func=task['func'], + schedule_type=task['schedule'], + minutes=task.get('minutes', None), + repeats=task.get('repeats', -1), + ) + except (ProgrammingError, OperationalError): + # Database might not yet be ready + logger.warning("register_tasks failed, database not ready") + + def unregister_tasks(self): + """ + Deregister the tasks with the database + """ + + try: + from django_q.models import Schedule + + for key, task in self.scheduled_tasks.items(): + + task_name = self.get_task_name(key) + + try: + scheduled_task = Schedule.objects.get(name=task_name) + scheduled_task.delete() + except Schedule.DoesNotExist: + pass + except (ProgrammingError, OperationalError): + # Database might not yet be ready + logger.warning("unregister_tasks failed, database not ready") + + class UrlsMixin: """ Mixin that enables custom URLs for the plugin @@ -112,7 +239,9 @@ class NavigationMixin: NAVIGATION_TAB_ICON = "fas fa-question" class MixinMeta: - """meta options for this mixin""" + """ + meta options for this mixin + """ MIXIN_NAME = 'Navigation Links' def __init__(self): diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py index 73223593a5..b7ae7d1fc4 100644 --- a/InvenTree/plugin/integration.py +++ b/InvenTree/plugin/integration.py @@ -94,6 +94,10 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin): def slug(self): return self.plugin_slug() + @property + def name(self): + return self.plugin_name() + @property def human_name(self): """human readable name for labels etc.""" diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index ceb5de5885..e9c910bb9e 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -1,9 +1,13 @@ -"""utility class to enable simpler imports""" -from ..builtin.integration.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin +""" +Utility class to enable simpler imports +""" + +from ..builtin.integration.mixins import AppMixin, SettingsMixin, ScheduleMixin, UrlsMixin, NavigationMixin __all__ = [ 'AppMixin', 'NavigationMixin', + 'ScheduleMixin', 'SettingsMixin', 'UrlsMixin', ] diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index fe28acfadb..45df8cf94b 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -262,24 +262,28 @@ class PluginsRegistry: logger.info(f'Found {len(plugins)} active plugins') self.activate_integration_settings(plugins) + self.activate_integration_schedule(plugins) self.activate_integration_app(plugins, force_reload=force_reload) def _deactivate_plugins(self): """ Run integration deactivation functions for all plugins """ + self.deactivate_integration_app() + self.deactivate_integration_schedule() self.deactivate_integration_settings() def activate_integration_settings(self, plugins): - from common.models import InvenTreeSetting - if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'): - logger.info('Registering IntegrationPlugin global settings') - for slug, plugin in plugins: - if plugin.mixin_enabled('settings'): - plugin_setting = plugin.settings - self.mixins_settings[slug] = plugin_setting + logger.info('Activating plugin settings') + + self.mixins_settings = {} + + for slug, plugin in plugins: + if plugin.mixin_enabled('settings'): + plugin_setting = plugin.settings + self.mixins_settings[slug] = plugin_setting def deactivate_integration_settings(self): @@ -290,10 +294,58 @@ class PluginsRegistry: plugin_settings.update(plugin_setting) # clear cache - self.mixins_Fsettings = {} + self.mixins_settings = {} + + def activate_integration_schedule(self, plugins): + + logger.info('Activating plugin tasks') + + from common.models import InvenTreeSetting + + # List of tasks we have activated + task_keys = [] + + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SCHEDULE'): + + for slug, plugin in plugins: + + if plugin.mixin_enabled('schedule'): + config = plugin.plugin_config() + + # Only active tasks for plugins which are enabled + if config and config.active: + plugin.register_tasks() + task_keys += plugin.get_task_names() + + if len(task_keys) > 0: + logger.info(f"Activated {len(task_keys)} scheduled tasks") + + # Remove any scheduled tasks which do not match + # This stops 'old' plugin tasks from accumulating + try: + from django_q.models import Schedule + + scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.") + + deleted_count = 0 + + for task in scheduled_plugin_tasks: + if task.name not in task_keys: + task.delete() + deleted_count += 1 + + if deleted_count > 0: + logger.info(f"Removed {deleted_count} old scheduled tasks") + except (ProgrammingError, OperationalError): + # Database might not yet be ready + logger.warning("activate_integration_schedule failed, database not ready") + + def deactivate_integration_schedule(self): + pass def activate_integration_app(self, plugins, force_reload=False): - """activate AppMixin plugins - add custom apps and reload + """ + Activate AppMixin plugins - add custom apps and reload :param plugins: list of IntegrationPlugins that should be installed :type plugins: dict @@ -377,7 +429,10 @@ class PluginsRegistry: return plugin_path def deactivate_integration_app(self): - """deactivate integration app - some magic required""" + """ + Deactivate integration app - some magic required + """ + # unregister models from admin for plugin_path in self.installed_apps: models = [] # the modelrefs need to be collected as poping an item in a iter is not welcomed diff --git a/InvenTree/plugin/samples/integration/scheduled_task.py b/InvenTree/plugin/samples/integration/scheduled_task.py new file mode 100644 index 0000000000..5a8f866cd7 --- /dev/null +++ b/InvenTree/plugin/samples/integration/scheduled_task.py @@ -0,0 +1,45 @@ +""" +Sample plugin which supports task scheduling +""" + +from plugin import IntegrationPluginBase +from plugin.mixins import ScheduleMixin + + +# Define some simple tasks to perform +def print_hello(): + print("Hello") + + +def print_world(): + print("World") + + +def fail_task(): + raise ValueError("This task should fail!") + + +class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): + """ + A sample plugin which provides support for scheduled tasks + """ + + PLUGIN_NAME = "ScheduledTasksPlugin" + PLUGIN_SLUG = "schedule" + PLUGIN_TITLE = "Scheduled Tasks" + + SCHEDULED_TASKS = { + 'hello': { + 'func': 'plugin.samples.integration.scheduled_task.print_hello', + 'schedule': 'I', + 'minutes': 5, + }, + 'world': { + 'func': 'plugin.samples.integration.scheduled_task.print_hello', + 'schedule': 'H', + }, + 'failure': { + 'func': 'plugin.samples.integration.scheduled_task.fail_task', + 'schedule': 'D', + }, + } diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html index 960ec852b8..858d0f3ab9 100644 --- a/InvenTree/templates/InvenTree/settings/plugin.html +++ b/InvenTree/templates/InvenTree/settings/plugin.html @@ -19,6 +19,7 @@
+ {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_SCHEDULE" icon="fa-calendar-alt" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %} @@ -28,7 +29,7 @@
-

{% trans "Plugin list" %}

+

{% trans "Plugins" %}

{% include "spacer.html" %}
{% url 'admin:plugin_pluginconfig_changelist' as url %}