From 1b8ad70fb6799dc880a32d467a97cfd780d2e878 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 19 Apr 2023 12:54:42 +0200 Subject: [PATCH] [FR] Refactor plugin registry (#4340) * add mixin order ref * move import * fix import order * reorder import * move activation/deactivation to mixins * move loaded/unloaded mixins out into seperate modules * fix deactivation sequence * switch to classmethods for loading * only run (de)activation if defined for mixin Fixes #4184 * fix deactivating * move reloading back to registry * fix merge error * move app mixin deactivation * fix migration reloading * reverse deactivation sequence * Revert "reverse deactivation sequence" This reverts commit aff17dd07d3c991549acf3ffa34002ccec2b6123. --- InvenTree/plugin/base/integration/AppMixin.py | 168 ++++++++++ .../plugin/base/integration/ScheduleMixin.py | 205 ++++++++++++ .../plugin/base/integration/SettingsMixin.py | 75 +++++ .../plugin/base/integration/UrlsMixin.py | 73 +++++ InvenTree/plugin/base/integration/mixins.py | 266 +--------------- InvenTree/plugin/mixins/__init__.py | 11 +- InvenTree/plugin/registry.py | 295 +++--------------- InvenTree/plugin/urls.py | 4 +- 8 files changed, 574 insertions(+), 523 deletions(-) create mode 100644 InvenTree/plugin/base/integration/AppMixin.py create mode 100644 InvenTree/plugin/base/integration/ScheduleMixin.py create mode 100644 InvenTree/plugin/base/integration/SettingsMixin.py create mode 100644 InvenTree/plugin/base/integration/UrlsMixin.py diff --git a/InvenTree/plugin/base/integration/AppMixin.py b/InvenTree/plugin/base/integration/AppMixin.py new file mode 100644 index 0000000000..6a0d15b9d0 --- /dev/null +++ b/InvenTree/plugin/base/integration/AppMixin.py @@ -0,0 +1,168 @@ +"""Plugin mixin class for AppMixin.""" +import logging +from importlib import reload + +from django.apps import apps +from django.conf import settings +from django.contrib import admin + +logger = logging.getLogger('inventree') + + +class AppMixin: + """Mixin that enables full django app functions for a plugin.""" + + class MixinMeta: + """Meta options for this mixin.""" + + MIXIN_NAME = 'App registration' + + def __init__(self): + """Register mixin.""" + super().__init__() + self.add_mixin('app', 'has_app', __class__) + + @classmethod + def _activate_mixin(cls, registry, plugins, force_reload=False, full_reload: bool = False): + """Activate AppMixin plugins - add custom apps and reload. + + Args: + registry (PluginRegistry): The registry that should be used + plugins (dict): List of IntegrationPlugins that should be installed + force_reload (bool, optional): Only reload base apps. Defaults to False. + full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. + """ + from common.models import InvenTreeSetting + + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'): + logger.info('Registering IntegrationPlugin apps') + apps_changed = False + + # add them to the INSTALLED_APPS + for _key, plugin in plugins: + if plugin.mixin_enabled('app'): + plugin_path = cls._get_plugin_path(plugin) + if plugin_path not in settings.INSTALLED_APPS: + settings.INSTALLED_APPS += [plugin_path] + registry.installed_apps += [plugin_path] + apps_changed = True + # if apps were changed or force loading base apps -> reload + if apps_changed or force_reload: + # first startup or force loading of base apps -> registry is prob false + if registry.apps_loading or force_reload: + registry.apps_loading = False + registry._reload_apps(force_reload=True, full_reload=full_reload) + else: + registry._reload_apps(full_reload=full_reload) + + # rediscover models/ admin sites + cls._reregister_contrib_apps(cls, registry) + + # update urls - must be last as models must be registered for creating admin routes + registry._update_urls() + + @classmethod + def _deactivate_mixin(cls, registry, force_reload: bool = False): + """Deactivate AppMixin plugins - some magic required. + + Args: + registry (PluginRegistry): The registry that should be used + force_reload (bool, optional): Also reload base apps. Defaults to False. + """ + # unregister models from admin + for plugin_path in registry.installed_apps: + models = [] # the modelrefs need to be collected as poping an item in a iter is not welcomed + app_name = plugin_path.split('.')[-1] + try: + app_config = apps.get_app_config(app_name) + + # check all models + for model in app_config.get_models(): + # remove model from admin site + try: + admin.site.unregister(model) + except Exception: # pragma: no cover + pass + models += [model._meta.model_name] + except LookupError: # pragma: no cover + # if an error occurs the app was never loaded right -> so nothing to do anymore + logger.debug(f'{app_name} App was not found during deregistering') + break + + # unregister the models (yes, models are just kept in multilevel dicts) + for model in models: + # remove model from general registry + apps.all_models[plugin_path].pop(model) + + # clear the registry for that app + # so that the import trick will work on reloading the same plugin + # -> the registry is kept for the whole lifecycle + if models and app_name in apps.all_models: + apps.all_models.pop(app_name) + + # remove plugin from installed_apps + registry._clean_installed_apps() + + # reset load flag and reload apps + settings.INTEGRATION_APPS_LOADED = False + registry._reload_apps(force_reload=force_reload) + + # update urls to remove the apps from the site admin + registry._update_urls() + + # region helpers + def _reregister_contrib_apps(self, registry): + """Fix reloading of contrib apps - models and admin. + + This is needed if plugins were loaded earlier and then reloaded as models and admins rely on imports. + Those register models and admin in their respective objects (e.g. admin.site for admin). + """ + for plugin_path in registry.installed_apps: + try: + app_name = plugin_path.split('.')[-1] + app_config = apps.get_app_config(app_name) + except LookupError: # pragma: no cover + # the plugin was never loaded correctly + logger.debug(f'{app_name} App was not found during deregistering') + break + + # reload models if they were set + # models_module gets set if models were defined - even after multiple loads + # on a reload the models registery is empty but models_module is not + if app_config.models_module and len(app_config.models) == 0: + reload(app_config.models_module) + + # check for all models if they are registered with the site admin + model_not_reg = False + for model in app_config.get_models(): + if not admin.site.is_registered(model): + model_not_reg = True + + # reload admin if at least one model is not registered + # models are registered with admin in the 'admin.py' file - so we check + # if the app_config has an admin module before trying to laod it + if model_not_reg and hasattr(app_config.module, 'admin'): + reload(app_config.module.admin) + + @classmethod + def _get_plugin_path(cls, plugin): + """Parse plugin path. + + The input can be eiter: + - a local file / dir + - a package + """ + try: + # for local path plugins + plugin_path = '.'.join(plugin.path().relative_to(settings.BASE_DIR).parts) + except ValueError: # pragma: no cover + # plugin is shipped as package - extract plugin module name + plugin_path = plugin.__module__.split('.')[0] + return plugin_path + +# endregion + + @property + def has_app(self): + """This plugin is always an app with this plugin.""" + return True diff --git a/InvenTree/plugin/base/integration/ScheduleMixin.py b/InvenTree/plugin/base/integration/ScheduleMixin.py new file mode 100644 index 0000000000..29ece250b2 --- /dev/null +++ b/InvenTree/plugin/base/integration/ScheduleMixin.py @@ -0,0 +1,205 @@ +"""Plugin mixin class for ScheduleMixin.""" +import logging + +from django.conf import settings +from django.db.utils import OperationalError, ProgrammingError + +from plugin.helpers import MixinImplementationError + +logger = logging.getLogger('inventree') + + +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') + }, + 'member_func': { + 'func': 'my_class_func', # Note, without the 'dot' notation, it will call a class member function + 'schedule': "H", # Once per hour + }, + } + + Note: 'schedule' parameter must be one of ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] + + Note: The 'func' argument can take two different forms: + - Dotted notation e.g. 'module.submodule.func' - calls a global function with the defined path + - Member notation e.g. 'my_func' (no dots!) - calls a member function of the calling class + """ + + ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] + + # Override this in subclass model + SCHEDULED_TASKS = {} + + class MixinMeta: + """Meta options for this mixin.""" + + MIXIN_NAME = 'Schedule' + + def __init__(self): + """Register mixin.""" + super().__init__() + self.scheduled_tasks = self.get_scheduled_tasks() + self.validate_scheduled_tasks() + + self.add_mixin('schedule', 'has_scheduled_tasks', __class__) + + @classmethod + def _activate_mixin(cls, registry, plugins, *args, **kwargs): + """Activate scheudles from plugins with the ScheduleMixin.""" + 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 _key, plugin in plugins: + + if plugin.mixin_enabled('schedule'): + + if plugin.is_active(): + # Only active tasks for plugins which are enabled + 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") # pragma: no cover + except (ProgrammingError, OperationalError): + # Database might not yet be ready + logger.warning("activate_integration_schedule failed, database not ready") + + def get_scheduled_tasks(self): + """Returns `SCHEDULED_TASKS` context. + + Override if you want the scheduled tasks to be dynamic (influenced by settings for example). + """ + return getattr(self, 'SCHEDULED_TASKS', {}) + + @property + def has_scheduled_tasks(self): + """Are tasks defined for this plugin.""" + 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 MixinImplementationError("SCHEDULED_TASKS not defined") + + for key, task in self.scheduled_tasks.items(): + + if 'func' not in task: + raise MixinImplementationError(f"Task '{key}' is missing 'func' parameter") + + if 'schedule' not in task: + raise MixinImplementationError(f"Task '{key}' is missing 'schedule' parameter") + + schedule = task['schedule'].upper().strip() + + if schedule not in self.ALLOWABLE_SCHEDULE_TYPES: + raise MixinImplementationError(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 MixinImplementationError(f"Task '{key}' is missing 'minutes' parameter") + + def get_task_name(self, key): + """Task name for key.""" + # Generate a 'unique' task name + slug = self.plugin_slug() + return f"plugin.{slug}.{key}" + + def get_task_names(self): + """All defined task names.""" + # 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) + + obj = { + 'name': task_name, + 'schedule_type': task['schedule'], + 'minutes': task.get('minutes', None), + 'repeats': task.get('repeats', -1), + } + + func_name = task['func'].strip() + + if '.' in func_name: + """Dotted notation indicates that we wish to run a globally defined function, from a specified Python module.""" + obj['func'] = func_name + else: + """Non-dotted notation indicates that we wish to call a 'member function' of the calling plugin. This is managed by the plugin registry itself.""" + slug = self.plugin_slug() + obj['func'] = 'plugin.registry.call_plugin_function' + obj['args'] = f"'{slug}', '{func_name}'" + + if Schedule.objects.filter(name=task_name).exists(): + # Scheduled task already exists - update it! + logger.info(f"Updating scheduled task '{task_name}'") + instance = Schedule.objects.get(name=task_name) + for item in obj: + setattr(instance, item, obj[item]) + instance.save() + else: + logger.info(f"Adding scheduled task '{task_name}'") + # Create a new scheduled task + Schedule.objects.create(**obj) + + except (ProgrammingError, OperationalError): # pragma: no cover + # 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, _ 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): # pragma: no cover + # Database might not yet be ready + logger.warning("unregister_tasks failed, database not ready") diff --git a/InvenTree/plugin/base/integration/SettingsMixin.py b/InvenTree/plugin/base/integration/SettingsMixin.py new file mode 100644 index 0000000000..d7937e9087 --- /dev/null +++ b/InvenTree/plugin/base/integration/SettingsMixin.py @@ -0,0 +1,75 @@ +"""Plugin mixin class for SettingsMixin.""" +import logging + +from django.db.utils import OperationalError, ProgrammingError + +logger = logging.getLogger('inventree') + + +class SettingsMixin: + """Mixin that enables global settings for the plugin.""" + + class MixinMeta: + """Meta for mixin.""" + MIXIN_NAME = 'Settings' + + def __init__(self): + """Register mixin.""" + super().__init__() + self.add_mixin('settings', 'has_settings', __class__) + self.settings = getattr(self, 'SETTINGS', {}) + + @classmethod + def _activate_mixin(cls, registry, plugins, *args, **kwargs): + """Activate plugin settings. + + Add all defined settings form the plugins to a unified dict in the registry. + This dict is referenced by the PluginSettings for settings definitions. + """ + logger.info('Activating plugin settings') + + registry.mixins_settings = {} + + for slug, plugin in plugins: + if plugin.mixin_enabled('settings'): + plugin_setting = plugin.settings + registry.mixins_settings[slug] = plugin_setting + + @classmethod + def _deactivate_mixin(cls, registry, **kwargs): + """Deactivate all plugin settings.""" + logger.info('Deactivating plugin settings') + # clear settings cache + registry.mixins_settings = {} + + @property + def has_settings(self): + """Does this plugin use custom global settings.""" + return bool(self.settings) + + def get_setting(self, key, cache=False): + """Return the 'value' of the setting associated with this plugin. + + Arguments: + key: The 'name' of the setting value to be retrieved + cache: Whether to use RAM cached value (default = False) + """ + from plugin.models import PluginSetting + + return PluginSetting.get_setting(key, plugin=self, cache=cache) + + def set_setting(self, key, value, user=None): + """Set plugin setting value by key.""" + from plugin.models import PluginConfig, PluginSetting + + try: + plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name()) + except (OperationalError, ProgrammingError): # pragma: no cover + plugin = None + + if not plugin: # pragma: no cover + # Cannot find associated plugin model, return + logger.error(f"Plugin configuration not found for plugin '{self.slug}'") + return + + PluginSetting.set_setting(key, value, user, plugin=plugin) diff --git a/InvenTree/plugin/base/integration/UrlsMixin.py b/InvenTree/plugin/base/integration/UrlsMixin.py new file mode 100644 index 0000000000..67f87455d1 --- /dev/null +++ b/InvenTree/plugin/base/integration/UrlsMixin.py @@ -0,0 +1,73 @@ +"""Plugin mixin class for UrlsMixin.""" +import logging + +from django.conf import settings +from django.urls import include, re_path + +from plugin.urls import PLUGIN_BASE + +logger = logging.getLogger('inventree') + + +class UrlsMixin: + """Mixin that enables custom URLs for the plugin.""" + + class MixinMeta: + """Meta options for this mixin.""" + + MIXIN_NAME = 'URLs' + + def __init__(self): + """Register mixin.""" + super().__init__() + self.add_mixin('urls', 'has_urls', __class__) + self.urls = self.setup_urls() + + @classmethod + def _activate_mixin(cls, registry, plugins, force_reload=False, full_reload: bool = False): + """Activate UrlsMixin plugins - add custom urls . + + Args: + registry (PluginRegistry): The registry that should be used + plugins (dict): List of IntegrationPlugins that should be installed + force_reload (bool, optional): Only reload base apps. Defaults to False. + full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. + """ + from common.models import InvenTreeSetting + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'): + logger.info('Registering UrlsMixin Plugin') + urls_changed = False + # check whether an activated plugin extends UrlsMixin + for _key, plugin in plugins: + if plugin.mixin_enabled('urls'): + urls_changed = True + # if apps were changed or force loading base apps -> reload + if urls_changed or force_reload or full_reload: + # update urls - must be last as models must be registered for creating admin routes + registry._update_urls() + + def setup_urls(self): + """Setup url endpoints for this plugin.""" + return getattr(self, 'URLS', None) + + @property + def base_url(self): + """Base url for this plugin.""" + return f'{PLUGIN_BASE}/{self.slug}/' + + @property + def internal_name(self): + """Internal url pattern name.""" + return f'plugin:{self.slug}:' + + @property + def urlpatterns(self): + """Urlpatterns for this plugin.""" + if self.has_urls: + return re_path(f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug) + return None + + @property + def has_urls(self): + """Does this plugin use custom urls.""" + return bool(self.urls) diff --git a/InvenTree/plugin/base/integration/mixins.py b/InvenTree/plugin/base/integration/mixins.py index 2551f00356..7faa21b061 100644 --- a/InvenTree/plugin/base/integration/mixins.py +++ b/InvenTree/plugin/base/integration/mixins.py @@ -3,216 +3,16 @@ import json as json_pkg import logging -from django.db.utils import OperationalError, ProgrammingError -from django.urls import include, re_path - import requests -import InvenTree.helpers import part.models import stock.models -from plugin.helpers import (MixinImplementationError, MixinNotImplementedError, - render_template, render_text) -from plugin.models import PluginConfig, PluginSetting -from plugin.urls import PLUGIN_BASE +from plugin.helpers import (MixinNotImplementedError, render_template, + render_text) logger = logging.getLogger('inventree') -class SettingsMixin: - """Mixin that enables global settings for the plugin.""" - - class MixinMeta: - """Meta for mixin.""" - MIXIN_NAME = 'Settings' - - def __init__(self): - """Register mixin.""" - super().__init__() - self.add_mixin('settings', 'has_settings', __class__) - self.settings = getattr(self, 'SETTINGS', {}) - - @property - def has_settings(self): - """Does this plugin use custom global settings.""" - return bool(self.settings) - - def get_setting(self, key, cache=False): - """Return the 'value' of the setting associated with this plugin. - - Arguments: - key: The 'name' of the setting value to be retrieved - cache: Whether to use RAM cached value (default = False) - """ - return PluginSetting.get_setting(key, plugin=self, cache=cache) - - def set_setting(self, key, value, user=None): - """Set plugin setting value by key.""" - try: - plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name()) - except (OperationalError, ProgrammingError): # pragma: no cover - plugin = None - - if not plugin: # pragma: no cover - # Cannot find associated plugin model, return - logger.error(f"Plugin configuration not found for plugin '{self.slug}'") - return - - 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') - }, - 'member_func': { - 'func': 'my_class_func', # Note, without the 'dot' notation, it will call a class member function - 'schedule': "H", # Once per hour - }, - } - - Note: 'schedule' parameter must be one of ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] - - Note: The 'func' argument can take two different forms: - - Dotted notation e.g. 'module.submodule.func' - calls a global function with the defined path - - Member notation e.g. 'my_func' (no dots!) - calls a member function of the calling class - """ - - ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] - - # Override this in subclass model - SCHEDULED_TASKS = {} - - class MixinMeta: - """Meta options for this mixin.""" - - MIXIN_NAME = 'Schedule' - - def __init__(self): - """Register mixin.""" - super().__init__() - self.scheduled_tasks = self.get_scheduled_tasks() - self.validate_scheduled_tasks() - - self.add_mixin('schedule', 'has_scheduled_tasks', __class__) - - def get_scheduled_tasks(self): - """Returns `SCHEDULED_TASKS` context. - - Override if you want the scheduled tasks to be dynamic (influenced by settings for example). - """ - return getattr(self, 'SCHEDULED_TASKS', {}) - - @property - def has_scheduled_tasks(self): - """Are tasks defined for this plugin.""" - 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 MixinImplementationError("SCHEDULED_TASKS not defined") - - for key, task in self.scheduled_tasks.items(): - - if 'func' not in task: - raise MixinImplementationError(f"Task '{key}' is missing 'func' parameter") - - if 'schedule' not in task: - raise MixinImplementationError(f"Task '{key}' is missing 'schedule' parameter") - - schedule = task['schedule'].upper().strip() - - if schedule not in self.ALLOWABLE_SCHEDULE_TYPES: - raise MixinImplementationError(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 MixinImplementationError(f"Task '{key}' is missing 'minutes' parameter") - - def get_task_name(self, key): - """Task name for key.""" - # Generate a 'unique' task name - slug = self.plugin_slug() - return f"plugin.{slug}.{key}" - - def get_task_names(self): - """All defined task names.""" - # 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) - - obj = { - 'name': task_name, - 'schedule_type': task['schedule'], - 'minutes': task.get('minutes', None), - 'repeats': task.get('repeats', -1), - } - - func_name = task['func'].strip() - - if '.' in func_name: - """Dotted notation indicates that we wish to run a globally defined function, from a specified Python module.""" - obj['func'] = func_name - else: - """Non-dotted notation indicates that we wish to call a 'member function' of the calling plugin. This is managed by the plugin registry itself.""" - slug = self.plugin_slug() - obj['func'] = 'plugin.registry.call_plugin_function' - obj['args'] = f"'{slug}', '{func_name}'" - - if Schedule.objects.filter(name=task_name).exists(): - # Scheduled task already exists - update it! - logger.info(f"Updating scheduled task '{task_name}'") - instance = Schedule.objects.get(name=task_name) - for item in obj: - setattr(instance, item, obj[item]) - instance.save() - else: - logger.info(f"Adding scheduled task '{task_name}'") - # Create a new scheduled task - Schedule.objects.create(**obj) - - except (ProgrammingError, OperationalError): # pragma: no cover - # 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, _ 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): # pragma: no cover - # Database might not yet be ready - logger.warning("unregister_tasks failed, database not ready") - - class ValidationMixin: """Mixin class that allows custom validation for various parts of InvenTree @@ -350,47 +150,6 @@ class ValidationMixin: return None -class UrlsMixin: - """Mixin that enables custom URLs for the plugin.""" - - class MixinMeta: - """Meta options for this mixin.""" - - MIXIN_NAME = 'URLs' - - def __init__(self): - """Register mixin.""" - super().__init__() - self.add_mixin('urls', 'has_urls', __class__) - self.urls = self.setup_urls() - - def setup_urls(self): - """Setup url endpoints for this plugin.""" - return getattr(self, 'URLS', None) - - @property - def base_url(self): - """Base url for this plugin.""" - return f'{PLUGIN_BASE}/{self.slug}/' - - @property - def internal_name(self): - """Internal url pattern name.""" - return f'plugin:{self.slug}:' - - @property - def urlpatterns(self): - """Urlpatterns for this plugin.""" - if self.has_urls: - return re_path(f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug) - return None - - @property - def has_urls(self): - """Does this plugin use custom urls.""" - return bool(self.urls) - - class NavigationMixin: """Mixin that enables custom navigation links with the plugin.""" @@ -437,25 +196,6 @@ class NavigationMixin: return getattr(self, 'NAVIGATION_TAB_ICON', "fas fa-question") -class AppMixin: - """Mixin that enables full django app functions for a plugin.""" - - class MixinMeta: - """Meta options for this mixin.""" - - MIXIN_NAME = 'App registration' - - def __init__(self): - """Register mixin.""" - super().__init__() - self.add_mixin('app', 'has_app', __class__) - - @property - def has_app(self): - """This plugin is always an app with this plugin.""" - return True - - class APICallMixin: """Mixin that enables easier API calls for a plugin. @@ -704,6 +444,8 @@ class PanelMixin: Returns: Array of panels """ + import InvenTree.helpers + panels = [] # Construct an updated context object for template rendering diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index c42c57b528..7fba7d1110 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -6,10 +6,13 @@ from common.notifications import (BulkNotificationMethod, from ..base.action.mixins import ActionMixin from ..base.barcodes.mixins import BarcodeMixin from ..base.event.mixins import EventMixin -from ..base.integration.mixins import (APICallMixin, AppMixin, NavigationMixin, - PanelMixin, ScheduleMixin, - SettingsContentMixin, SettingsMixin, - UrlsMixin, ValidationMixin) +from ..base.integration.AppMixin import AppMixin +from ..base.integration.mixins import (APICallMixin, NavigationMixin, + PanelMixin, SettingsContentMixin, + ValidationMixin) +from ..base.integration.ScheduleMixin import ScheduleMixin +from ..base.integration.SettingsMixin import SettingsMixin +from ..base.integration.UrlsMixin import UrlsMixin from ..base.label.mixins import LabelPrintingMixin from ..base.locate.mixins import LocateMixin diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index e8dac3f71e..1e86f1ff9b 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -9,7 +9,6 @@ import importlib import logging import os import subprocess -from importlib import reload from pathlib import Path from typing import Dict, List, OrderedDict @@ -36,7 +35,13 @@ logger = logging.getLogger('inventree') class PluginsRegistry: """The PluginsRegistry class.""" - def __init__(self) -> None: + from .base.integration.AppMixin import AppMixin + from .base.integration.ScheduleMixin import ScheduleMixin + from .base.integration.SettingsMixin import SettingsMixin + from .base.integration.UrlsMixin import UrlsMixin + DEFAULT_MIXIN_ORDER = [SettingsMixin, ScheduleMixin, AppMixin, UrlsMixin] + + def __init__(self, mixin_order: list = None) -> None: """Initialize registry. Set up all needed references for internal and external states. @@ -59,6 +64,7 @@ class PluginsRegistry: # mixins self.mixins_settings = {} + self.mixin_order = mixin_order or self.DEFAULT_MIXIN_ORDER def get_plugin(self, slug): """Lookup plugin by slug (unique key).""" @@ -472,10 +478,11 @@ class PluginsRegistry: plugins = self.plugins.items() logger.info(f'Found {len(plugins)} active plugins') - self.activate_plugin_settings(plugins) - self.activate_plugin_schedule(plugins) - self.activate_plugin_app(plugins, force_reload=force_reload, full_reload=full_reload) - self.activate_plugin_url(plugins, force_reload=force_reload, full_reload=full_reload) + for mixin in self.mixin_order: + if hasattr(mixin, '_activate_mixin'): + mixin._activate_mixin(self, plugins, force_reload=force_reload, full_reload=full_reload) + + logger.info('Done activating') def _deactivate_plugins(self, force_reload: bool = False): """Run deactivation functions for all plugins. @@ -483,235 +490,44 @@ class PluginsRegistry: Args: force_reload (bool, optional): Also reload base apps. Defaults to False. """ - self.deactivate_plugin_app(force_reload=force_reload) - self.deactivate_plugin_schedule() - self.deactivate_plugin_settings() + for mixin in self.mixin_order: + if hasattr(mixin, '_deactivate_mixin'): + mixin._deactivate_mixin(self, force_reload=force_reload) + + logger.info('Done deactivating') # endregion # region mixin specific loading ... - def activate_plugin_settings(self, plugins): - """Activate plugin settings. + def _try_reload(self, cmd, *args, **kwargs): + """Wrapper to try reloading the apps. - Add all defined settings form the plugins to a unified dict in the registry. - This dict is referenced by the PluginSettings for settings definitions. - """ - 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_plugin_settings(self): - """Deactivate all plugin settings.""" - logger.info('Deactivating plugin settings') - # clear settings cache - self.mixins_settings = {} - - def activate_plugin_schedule(self, plugins): - """Activate scheudles from plugins with the ScheduleMixin.""" - 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 _key, plugin in plugins: - - if plugin.mixin_enabled('schedule'): - - if plugin.is_active(): - # Only active tasks for plugins which are enabled - 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") # pragma: no cover - except (ProgrammingError, OperationalError): - # Database might not yet be ready - logger.warning("activate_integration_schedule failed, database not ready") - - def deactivate_plugin_schedule(self): - """Deactivate ScheduleMixin. - - Currently nothing is done here. - """ - pass - - def activate_plugin_app(self, plugins, force_reload=False, full_reload: bool = False): - """Activate AppMixin plugins - add custom apps and reload. - - Args: - plugins (dict): List of IntegrationPlugins that should be installed - force_reload (bool, optional): Only reload base apps. Defaults to False. - full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. - """ - from common.models import InvenTreeSetting - - if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'): - logger.info('Registering IntegrationPlugin apps') - apps_changed = False - - # add them to the INSTALLED_APPS - for _key, plugin in plugins: - if plugin.mixin_enabled('app'): - plugin_path = self._get_plugin_path(plugin) - if plugin_path not in settings.INSTALLED_APPS: - settings.INSTALLED_APPS += [plugin_path] - self.installed_apps += [plugin_path] - apps_changed = True - # if apps were changed or force loading base apps -> reload - if apps_changed or force_reload: - # first startup or force loading of base apps -> registry is prob false - if self.apps_loading or force_reload: - self.apps_loading = False - self._reload_apps(force_reload=True, full_reload=full_reload) - else: - self._reload_apps(full_reload=full_reload) - - # rediscover models/ admin sites - self._reregister_contrib_apps() - - # update urls - must be last as models must be registered for creating admin routes - self._update_urls() - - def activate_plugin_url(self, plugins, force_reload=False, full_reload: bool = False): - """Activate UrlsMixin plugins - add custom urls . - - Args: - plugins (dict): List of IntegrationPlugins that should be installed - force_reload (bool, optional): Only reload base apps. Defaults to False. - full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. - """ - from common.models import InvenTreeSetting - if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'): - logger.info('Registering UrlsMixin Plugin') - urls_changed = False - # check whether an activated plugin extends UrlsMixin - for _key, plugin in plugins: - if plugin.mixin_enabled('urls'): - urls_changed = True - # if apps were changed or force loading base apps -> reload - if urls_changed or force_reload or full_reload: - # update urls - must be last as models must be registered for creating admin routes - self._update_urls() - - def _reregister_contrib_apps(self): - """Fix reloading of contrib apps - models and admin. - - This is needed if plugins were loaded earlier and then reloaded as models and admins rely on imports. - Those register models and admin in their respective objects (e.g. admin.site for admin). - """ - for plugin_path in self.installed_apps: - try: - app_name = plugin_path.split('.')[-1] - app_config = apps.get_app_config(app_name) - except LookupError: # pragma: no cover - # the plugin was never loaded correctly - logger.debug(f'{app_name} App was not found during deregistering') - break - - # reload models if they were set - # models_module gets set if models were defined - even after multiple loads - # on a reload the models registery is empty but models_module is not - if app_config.models_module and len(app_config.models) == 0: - reload(app_config.models_module) - - # check for all models if they are registered with the site admin - model_not_reg = False - for model in app_config.get_models(): - if not admin.site.is_registered(model): - model_not_reg = True - - # reload admin if at least one model is not registered - # models are registered with admin in the 'admin.py' file - so we check - # if the app_config has an admin module before trying to laod it - if model_not_reg and hasattr(app_config.module, 'admin'): - reload(app_config.module.admin) - - def _get_plugin_path(self, plugin): - """Parse plugin path. - - The input can be eiter: - - a local file / dir - - a package + Throws an custom error that gets handled by the loading function. """ try: - # for local path plugins - plugin_path = '.'.join(plugin.path().relative_to(settings.BASE_DIR).parts) - except ValueError: # pragma: no cover - # plugin is shipped as package - extract plugin module name - plugin_path = plugin.__module__.split('.')[0] - return plugin_path + cmd(*args, **kwargs) + return True, [] + except Exception as error: # pragma: no cover + handle_error(error) - def deactivate_plugin_app(self, force_reload: bool = False): - """Deactivate AppMixin plugins - some magic required. + def _reload_apps(self, force_reload: bool = False, full_reload: bool = False): + """Internal: reload apps using django internal functions. Args: force_reload (bool, optional): Also reload base apps. Defaults to False. + full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. """ - # 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 - app_name = plugin_path.split('.')[-1] - try: - app_config = apps.get_app_config(app_name) - - # check all models - for model in app_config.get_models(): - # remove model from admin site - try: - admin.site.unregister(model) - except Exception: # pragma: no cover - pass - models += [model._meta.model_name] - except LookupError: # pragma: no cover - # if an error occurs the app was never loaded right -> so nothing to do anymore - logger.debug(f'{app_name} App was not found during deregistering') - break - - # unregister the models (yes, models are just kept in multilevel dicts) - for model in models: - # remove model from general registry - apps.all_models[plugin_path].pop(model) - - # clear the registry for that app - # so that the import trick will work on reloading the same plugin - # -> the registry is kept for the whole lifecycle - if models and app_name in apps.all_models: - apps.all_models.pop(app_name) - - # remove plugin from installed_apps - self._clean_installed_apps() - - # reset load flag and reload apps - settings.INTEGRATION_APPS_LOADED = False - self._reload_apps(force_reload=force_reload) - - # update urls to remove the apps from the site admin - self._update_urls() + # If full_reloading is set to true we do not want to set the flag + if not full_reload: + self.is_loading = True # set flag to disable loop reloading + if force_reload: + # we can not use the built in functions as we need to brute force the registry + apps.app_configs = OrderedDict() + apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False + apps.clear_cache() + self._try_reload(apps.populate, settings.INSTALLED_APPS) + else: + self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS) + self.is_loading = False def _clean_installed_apps(self): for plugin in self.installed_apps: @@ -741,37 +557,6 @@ class PluginsRegistry: # Replace frontendpatterns global_pattern[0] = re_path('', include(urlpattern)) clear_url_caches() - - def _reload_apps(self, force_reload: bool = False, full_reload: bool = False): - """Internal: reload apps using django internal functions. - - Args: - force_reload (bool, optional): Also reload base apps. Defaults to False. - full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. - """ - # If full_reloading is set to true we do not want to set the flag - if not full_reload: - self.is_loading = True # set flag to disable loop reloading - if force_reload: - # we can not use the built in functions as we need to brute force the registry - apps.app_configs = OrderedDict() - apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False - apps.clear_cache() - self._try_reload(apps.populate, settings.INSTALLED_APPS) - else: - self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS) - self.is_loading = False - - def _try_reload(self, cmd, *args, **kwargs): - """Wrapper to try reloading the apps. - - Throws an custom error that gets handled by the loading function. - """ - try: - cmd(*args, **kwargs) - return True, [] - except Exception as error: # pragma: no cover - handle_error(error) # endregion diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py index 6da54a83ee..ece2691e61 100644 --- a/InvenTree/plugin/urls.py +++ b/InvenTree/plugin/urls.py @@ -2,13 +2,13 @@ from django.urls import include, re_path -from plugin import registry - PLUGIN_BASE = 'plugin' # Constant for links def get_plugin_urls(): """Returns a urlpattern that can be integrated into the global urls.""" + from plugin import registry + urls = [] for plugin in registry.plugins.values():