mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge pull request #2512 from SchrodingersGat/mixins
Adds "scheduled task" mixin for plugins
This commit is contained in:
		| @@ -571,7 +571,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): | |||||||
|         super().save() |         super().save() | ||||||
|  |  | ||||||
|         if self.requires_restart(): |         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: |     Dict of all global settings values: | ||||||
| @@ -978,6 +978,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): | |||||||
|             'validator': bool, |             'validator': bool, | ||||||
|             'requires_restart': True, |             '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: |     class Meta: | ||||||
|   | |||||||
| @@ -2,6 +2,8 @@ | |||||||
| Plugin mixin classes | Plugin mixin classes | ||||||
| """ | """ | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  |  | ||||||
| from django.conf.urls import url, include | from django.conf.urls import url, include | ||||||
| from django.db.utils import OperationalError, ProgrammingError | from django.db.utils import OperationalError, ProgrammingError | ||||||
|  |  | ||||||
| @@ -9,6 +11,9 @@ from plugin.models import PluginConfig, PluginSetting | |||||||
| from plugin.urls import PLUGIN_BASE | from plugin.urls import PLUGIN_BASE | ||||||
|  |  | ||||||
|  |  | ||||||
|  | logger = logging.getLogger('inventree') | ||||||
|  |  | ||||||
|  |  | ||||||
| class SettingsMixin: | class SettingsMixin: | ||||||
|     """ |     """ | ||||||
|     Mixin that enables global settings for the plugin |     Mixin that enables global settings for the plugin | ||||||
| @@ -53,6 +58,128 @@ class SettingsMixin: | |||||||
|         PluginSetting.set_setting(key, value, user, plugin=plugin) |         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: | class UrlsMixin: | ||||||
|     """ |     """ | ||||||
|     Mixin that enables custom URLs for the plugin |     Mixin that enables custom URLs for the plugin | ||||||
| @@ -112,7 +239,9 @@ class NavigationMixin: | |||||||
|     NAVIGATION_TAB_ICON = "fas fa-question" |     NAVIGATION_TAB_ICON = "fas fa-question" | ||||||
|  |  | ||||||
|     class MixinMeta: |     class MixinMeta: | ||||||
|         """meta options for this mixin""" |         """ | ||||||
|  |         meta options for this mixin | ||||||
|  |         """ | ||||||
|         MIXIN_NAME = 'Navigation Links' |         MIXIN_NAME = 'Navigation Links' | ||||||
|  |  | ||||||
|     def __init__(self): |     def __init__(self): | ||||||
|   | |||||||
| @@ -94,6 +94,10 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin): | |||||||
|     def slug(self): |     def slug(self): | ||||||
|         return self.plugin_slug() |         return self.plugin_slug() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def name(self): | ||||||
|  |         return self.plugin_name() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def human_name(self): |     def human_name(self): | ||||||
|         """human readable name for labels etc.""" |         """human readable name for labels etc.""" | ||||||
|   | |||||||
| @@ -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__ = [ | __all__ = [ | ||||||
|     'AppMixin', |     'AppMixin', | ||||||
|     'NavigationMixin', |     'NavigationMixin', | ||||||
|  |     'ScheduleMixin', | ||||||
|     'SettingsMixin', |     'SettingsMixin', | ||||||
|     'UrlsMixin', |     'UrlsMixin', | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -262,24 +262,28 @@ class PluginsRegistry: | |||||||
|         logger.info(f'Found {len(plugins)} active plugins') |         logger.info(f'Found {len(plugins)} active plugins') | ||||||
|  |  | ||||||
|         self.activate_integration_settings(plugins) |         self.activate_integration_settings(plugins) | ||||||
|  |         self.activate_integration_schedule(plugins) | ||||||
|         self.activate_integration_app(plugins, force_reload=force_reload) |         self.activate_integration_app(plugins, force_reload=force_reload) | ||||||
|  |  | ||||||
|     def _deactivate_plugins(self): |     def _deactivate_plugins(self): | ||||||
|         """ |         """ | ||||||
|         Run integration deactivation functions for all plugins |         Run integration deactivation functions for all plugins | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         self.deactivate_integration_app() |         self.deactivate_integration_app() | ||||||
|  |         self.deactivate_integration_schedule() | ||||||
|         self.deactivate_integration_settings() |         self.deactivate_integration_settings() | ||||||
|  |  | ||||||
|     def activate_integration_settings(self, plugins): |     def activate_integration_settings(self, plugins): | ||||||
|         from common.models import InvenTreeSetting |  | ||||||
|  |  | ||||||
|         if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'): |         logger.info('Activating plugin settings') | ||||||
|             logger.info('Registering IntegrationPlugin global settings') |  | ||||||
|             for slug, plugin in plugins: |         self.mixins_settings = {} | ||||||
|                 if plugin.mixin_enabled('settings'): |  | ||||||
|                     plugin_setting = plugin.settings |         for slug, plugin in plugins: | ||||||
|                     self.mixins_settings[slug] = plugin_setting |             if plugin.mixin_enabled('settings'): | ||||||
|  |                 plugin_setting = plugin.settings | ||||||
|  |                 self.mixins_settings[slug] = plugin_setting | ||||||
|  |  | ||||||
|     def deactivate_integration_settings(self): |     def deactivate_integration_settings(self): | ||||||
|  |  | ||||||
| @@ -290,10 +294,58 @@ class PluginsRegistry: | |||||||
|             plugin_settings.update(plugin_setting) |             plugin_settings.update(plugin_setting) | ||||||
|  |  | ||||||
|         # clear cache |         # 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): |     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 |         :param plugins: list of IntegrationPlugins that should be installed | ||||||
|         :type plugins: dict |         :type plugins: dict | ||||||
| @@ -377,7 +429,10 @@ class PluginsRegistry: | |||||||
|         return plugin_path |         return plugin_path | ||||||
|  |  | ||||||
|     def deactivate_integration_app(self): |     def deactivate_integration_app(self): | ||||||
|         """deactivate integration app - some magic required""" |         """ | ||||||
|  |         Deactivate integration app - some magic required | ||||||
|  |         """ | ||||||
|  |  | ||||||
|         # unregister models from admin |         # unregister models from admin | ||||||
|         for plugin_path in self.installed_apps: |         for plugin_path in self.installed_apps: | ||||||
|             models = []  # the modelrefs need to be collected as poping an item in a iter is not welcomed |             models = []  # the modelrefs need to be collected as poping an item in a iter is not welcomed | ||||||
|   | |||||||
							
								
								
									
										45
									
								
								InvenTree/plugin/samples/integration/scheduled_task.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								InvenTree/plugin/samples/integration/scheduled_task.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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', | ||||||
|  |         }, | ||||||
|  |     } | ||||||
| @@ -19,6 +19,7 @@ | |||||||
| <div class='table-responsive'> | <div class='table-responsive'> | ||||||
| <table class='table table-striped table-condensed'> | <table class='table table-striped table-condensed'> | ||||||
|     <tbody> |     <tbody> | ||||||
|  |         {% 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_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_NAVIGATION" icon="fa-sitemap" %} | ||||||
|         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %} |         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %} | ||||||
| @@ -28,7 +29,7 @@ | |||||||
|  |  | ||||||
| <div class='panel-heading'> | <div class='panel-heading'> | ||||||
|     <div class='d-flex flex-wrap'> |     <div class='d-flex flex-wrap'> | ||||||
|         <h4>{% trans "Plugin list" %}</h4> |         <h4>{% trans "Plugins" %}</h4> | ||||||
|         {% include "spacer.html" %} |         {% include "spacer.html" %} | ||||||
|         <div class='btn-group' role='group'> |         <div class='btn-group' role='group'> | ||||||
|             {% url 'admin:plugin_pluginconfig_changelist' as url %} |             {% url 'admin:plugin_pluginconfig_changelist' as url %} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user