[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 aff17dd07d.
This commit is contained in:
Matthias Mair 2023-04-19 12:54:42 +02:00 committed by GitHub
parent 5cd74c4190
commit 1b8ad70fb6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 574 additions and 523 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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