Coverage for src/backend/InvenTree/plugin/registry.py: 63%
522 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Registry for loading and managing multiple plugins at run-time.
3- Holds the class and the object that contains all code to maintain plugin states
4- Manages setup and teardown of plugin class instances
5"""
7import functools
8import importlib
9import importlib.util
10import os
11import sys
12import time
13from collections import OrderedDict
14from importlib.machinery import SourceFileLoader
15from pathlib import Path
16from threading import Lock
17from typing import Any, Optional
19from django.apps import apps
20from django.conf import settings
21from django.contrib import admin
22from django.db.utils import IntegrityError, OperationalError, ProgrammingError
23from django.urls import clear_url_caches, path
24from django.utils.text import slugify
25from django.utils.translation import gettext_lazy as _
27import structlog
29import InvenTree.cache
30import InvenTree.ready
31from common.settings import get_global_setting, set_global_setting
32from InvenTree.config import get_plugin_dir
33from InvenTree.exceptions import log_error
35from .helpers import (
36 IntegrationPluginError,
37 MixinNotImplementedError,
38 get_entrypoints,
39 get_plugins,
40 handle_error,
41 log_registry_error,
42)
43from .plugin import InvenTreePlugin
45logger = structlog.get_logger('inventree')
48def registry_entrypoint(check_reload: bool = True, default_value: Any = None) -> Any:
49 """Function decorator for registry entrypoints methods.
51 - Ensure that the registry is ready before calling the method.
52 - Check if the registry needs to be reloaded.
53 """
55 def decorator(method):
56 """Decorator to ensure registry is ready before calling the method."""
58 @functools.wraps(method)
59 def wrapper(self, *args, **kwargs):
60 """Wrapper function to ensure registry is ready."""
61 if not self.ready: 61 ↛ 62line 61 didn't jump to line 62 because the condition on line 61 was never true
62 logger.warning(
63 "Plugin registry is not ready - cannot call method '%s'",
64 method.__name__,
65 )
66 return default_value
68 # Check if the registry needs to be reloaded
69 if check_reload:
70 self.check_reload()
72 return method(self, *args, **kwargs)
74 return wrapper
76 return decorator
79class PluginsRegistry:
80 """The PluginsRegistry class."""
82 from .base.integration.AppMixin import AppMixin
83 from .base.integration.ScheduleMixin import ScheduleMixin
84 from .base.integration.SettingsMixin import SettingsMixin
85 from .base.integration.UrlsMixin import UrlsMixin
87 DEFAULT_MIXIN_ORDER = [SettingsMixin, ScheduleMixin, AppMixin, UrlsMixin]
89 # This list of plugins are *always* enabled, and are loaded by default
90 # This is because they provide core functionality to the InvenTree system
91 # Other 'builtin' plugins are automatically loaded, but can be disabled by the user
92 MANDATORY_PLUGINS = [
93 'inventreebarcode',
94 'bom-exporter',
95 'inventree-exporter',
96 'inventree-ui-notification',
97 'inventree-machines',
98 'inventree-email-notification',
99 'inventreecurrencyexchange',
100 'inventreelabel',
101 'inventreelabelmachine',
102 'parameter-exporter',
103 ]
105 ready: bool
107 def __init__(self) -> None:
108 """Initialize registry.
110 Set up all needed references for internal and external states.
111 """
112 # plugin registry
113 self.plugins: dict[str, InvenTreePlugin] = {} # List of active instances
114 self.plugins_inactive: dict[
115 str, InvenTreePlugin
116 ] = {} # List of inactive instances
117 self.plugins_full: dict[
118 str, InvenTreePlugin
119 ] = {} # List of all plugin instances
121 self.ready = False # Marks if the registry is ready to be used
123 # Keep an internal hash of the plugin registry state
124 self.registry_hash: Optional[str] = None
126 self.plugin_modules: list[InvenTreePlugin] = [] # Holds all discovered plugins
127 self.mixin_modules: dict[str, Any] = {} # Holds all discovered mixins
129 self.errors: dict[str, list[Any]] = {} # Holds errors discovered during loading
131 self.loading_lock = Lock() # Lock to prevent multiple loading at the same time
133 # flags
134 self.plugins_loaded = (
135 False # Marks if the registry fully loaded and all django apps are reloaded
136 )
137 self.apps_loading = True # Marks if apps were reloaded yet
139 self.installed_apps = [] # Holds all added plugin_paths
141 def set_ready(self):
142 """Set the registry as ready to be used.
144 This method should only be called once per application start,
145 after all apps have been loaded and the registry is fully initialized.
146 """
147 from common.models import InvenTreeSetting
149 logger.info('Initializing plugin registry')
151 self.ready = True
153 # Install plugins from file (if required)
154 if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False, cache=False): 154 ↛ 169line 154 didn't jump to line 169 because the condition on line 154 was always true
155 if InvenTree.ready.isInTestMode() and not settings.PLUGIN_TESTING: 155 ↛ 157line 155 didn't jump to line 157 because the condition on line 155 was never true
156 # Ignore plugin reload in test mode
157 pass
158 elif InvenTree.ready.isRunningBackup(): 158 ↛ 160line 158 didn't jump to line 160 because the condition on line 158 was never true
159 # Ignore plugin reload during backup
160 pass
161 elif InvenTree.ready.isGeneratingSchema(): 161 ↛ 163line 161 didn't jump to line 163 because the condition on line 161 was never true
162 # Ignore plugin reload during schema generation
163 pass
164 elif InvenTree.ready.isInWorkerThread() or InvenTree.ready.isInMainThread(): 164 ↛ 169line 164 didn't jump to line 169 because the condition on line 164 was always true
165 # make sure all plugins are installed
166 registry.install_plugin_file()
168 # Perform initial plugin discovery
169 self.reload_plugins(full_reload=True, force_reload=True, collect=True)
171 @property
172 def is_ready(self) -> bool:
173 """Return True if the plugin registry is ready to be used."""
174 return self.ready
176 @property
177 def is_loading(self) -> bool:
178 """Return True if the plugin registry is currently loading."""
179 return self.loading_lock.locked()
181 @registry_entrypoint()
182 def get_plugin(
183 self, slug: str, active: bool = True, with_mixin: Optional[str] = None
184 ) -> InvenTreePlugin:
185 """Lookup plugin by slug (unique key).
187 Args:
188 slug (str): The slug of the plugin to look up.
189 active (bool, optional): Filter by 'active' status of the plugin. If None, no filtering is applied. Defaults to True.
190 with_mixin (str, optional): Filter by mixin name. If None, no filtering is applied. Defaults to None.
192 Returns:
193 InvenTreePlugin or None: The plugin instance if found, otherwise None.
194 """
195 if slug not in self.plugins:
196 logger.warning("Plugin registry has no record of plugin '%s'", slug)
197 return None
199 plg = self.plugins[slug]
201 config = self.get_plugin_config(slug)
203 if not config: # pragma: no cover 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 logger.warning("Plugin '%s' has no configuration", slug)
205 return None
207 if active is not None and active != config.is_active(): 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 return None
210 if with_mixin is not None and not plg.mixin_enabled(with_mixin): 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 return None
213 return plg
215 def get_plugin_config(self, slug: str, name: str | None = None):
216 """Return the matching PluginConfig instance for a given plugin.
218 Arguments:
219 slug: The plugin slug
220 name: The plugin name (optional)
221 """
222 import InvenTree.ready
223 from plugin.models import PluginConfig
225 # Under certain circumstances, we want to avoid creating new PluginConfig instances in the database
226 can_create = (
227 InvenTree.ready.canAppAccessDatabase(
228 allow_plugins=False, allow_shell=True, allow_test=True
229 )
230 and not InvenTree.ready.isReadOnlyCommand()
231 )
233 try:
234 cfg = PluginConfig.objects.filter(key=slug).first()
236 if not cfg and can_create:
237 logger.debug(
238 "get_plugin_config: Creating new PluginConfig for '%s'", slug
239 )
240 cfg = PluginConfig.objects.create(key=slug)
242 except PluginConfig.DoesNotExist: # pragma: no cover
243 return None
244 except (IntegrityError, OperationalError, ProgrammingError): # pragma: no cover
245 return None
247 if name and cfg.name != name:
248 # Update the name if it has changed
249 try:
250 cfg.name = name
251 cfg.save()
252 except Exception:
253 logger.exception('Failed to update plugin name')
255 return cfg
257 @registry_entrypoint()
258 def set_plugin_state(self, slug: str, state: bool):
259 """Set the state(active/inactive) of a plugin.
261 Arguments:
262 slug (str): Plugin slug
263 state (bool): Plugin state - true = active, false = inactive
264 """
265 if slug not in self.plugins_full:
266 logger.warning("Plugin registry has no record of plugin '%s'", slug)
267 return
269 cfg = self.get_plugin_config(slug)
270 cfg.active = state
271 cfg.save()
273 # Update the registry hash value
274 self.update_plugin_hash()
276 @registry_entrypoint()
277 def call_plugin_function(self, slug: str, func: str, *args, **kwargs):
278 """Call a member function (named by 'func') of the plugin named by 'slug'.
280 As this is intended to be run by the background worker,
281 we do not perform any try/except here.
283 Instead, any error messages are returned to the worker.
284 """
285 raise_error = kwargs.pop('raise_error', True)
287 plugin = self.get_plugin(slug)
289 if not plugin:
290 if raise_error:
291 raise AttributeError(f"Plugin '{slug}' not found")
292 return
294 plugin_func = getattr(plugin, func)
296 if not plugin_func or not callable(plugin_func):
297 if raise_error:
298 raise AttributeError(f"Plugin '{slug}' has no callable method '{func}'")
299 return
301 return plugin_func(*args, **kwargs)
303 # region registry functions
305 @registry_entrypoint(default_value=[])
306 def with_mixin(
307 self, mixin: str, active: Optional[bool] = True, builtin: Optional[bool] = None
308 ) -> list[InvenTreePlugin]:
309 """Returns reference to all plugins that have a specified mixin enabled.
311 Args:
312 mixin (str): Mixin name
313 active (bool, optional): Filter by 'active' status of plugin. Defaults to True.
314 builtin (bool, optional): Filter by 'builtin' status of plugin. Defaults to None.
315 """
316 # We can store the PluginConfig objects against the session cache,
317 # which allows us to avoid hitting the database multiple times (per session)
318 # As we have already checked the registry hash, this is a valid cache key
319 cache_key = f'plugin_configs:{self.registry_hash}'
321 configs = InvenTree.cache.get_session_cache(cache_key)
323 if not configs:
324 try:
325 # Pre-fetch the PluginConfig objects to avoid multiple database queries
326 from plugin.models import PluginConfig
328 plugin_configs = PluginConfig.objects.all()
329 configs = {config.key: config for config in plugin_configs}
330 InvenTree.cache.set_session_cache(cache_key, configs)
331 except (ProgrammingError, OperationalError):
332 # The database is not ready yet
333 logger.warning('plugin.registry.with_mixin: Database not ready')
334 return []
336 mixin = str(mixin).lower().strip()
338 plugins = []
340 for plugin in self.plugins.values():
341 try:
342 if not plugin.mixin_enabled(mixin):
343 continue
344 except MixinNotImplementedError:
345 continue
347 config = configs.get(plugin.slug) or plugin.plugin_config()
349 # No config - cannot use this plugin
350 if not config: 350 ↛ 351line 350 didn't jump to line 351 because the condition on line 350 was never true
351 continue
353 if active is not None and active != config.is_active(): 353 ↛ 354line 353 didn't jump to line 354 because the condition on line 353 was never true
354 continue
356 if builtin is not None and builtin != config.is_builtin(): 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 continue
359 plugins.append(plugin)
361 return plugins
363 # endregion
365 # region loading / unloading
366 def _load_plugins(
367 self, full_reload: bool = False, _internal: Optional[list] = None
368 ):
369 """Load and activate all IntegrationPlugins.
371 Args:
372 full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
373 _internal (list, optional): Internal apps to reload (used for testing). Defaults to None
374 """
375 logger.info('Loading plugins')
377 try:
378 self._init_plugins()
379 self._activate_plugins(full_reload=full_reload, _internal=_internal)
380 except (OperationalError, ProgrammingError, IntegrityError):
381 # Exception if the database has not been migrated yet, or is not ready
382 pass
383 except IntegrationPluginError:
384 # Plugin specific error has already been handled
385 pass
387 # ensure plugins_loaded is True
388 self.plugins_loaded = True
390 logger.debug('Finished loading plugins')
392 # Trigger plugins_loaded event
393 if InvenTree.ready.canAppAccessDatabase(): 393 ↛ exitline 393 didn't return from function '_load_plugins' because the condition on line 393 was always true
394 from plugin.events import PluginEvents, trigger_event
396 trigger_event(PluginEvents.PLUGINS_LOADED)
398 def _unload_plugins(self, force_reload: bool = False):
399 """Unload and deactivate all IntegrationPlugins.
401 Args:
402 force_reload (bool, optional): Also reload base apps. Defaults to False.
403 """
404 logger.info('Start unloading plugins')
406 # remove all plugins from registry
407 self._clean_registry()
409 # deactivate all integrations
410 self._deactivate_plugins(force_reload=force_reload)
412 logger.info('Finished unloading plugins')
414 @registry_entrypoint(check_reload=False)
415 def reload_plugins(
416 self,
417 full_reload: bool = False,
418 force_reload: bool = False,
419 collect: bool = False,
420 clear_errors: bool = False,
421 _internal: Optional[list] = None,
422 ):
423 """Reload the plugin registry.
425 This should be considered the single point of entry for loading plugins!
427 Args:
428 full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
429 force_reload (bool, optional): Also reload base apps. Defaults to False.
430 collect (bool, optional): Collect plugins before reloading. Defaults to False.
431 clear_errors (bool, optional): Clear any previous loading errors. Defaults to False.
432 _internal (list, optional): Internal apps to reload (used for testing). Defaults to None
433 """
434 # Do not reload when currently loading
435 if self.is_loading:
436 logger.debug('Skipping reload - plugin registry is currently loading')
437 return
439 if not self.loading_lock.acquire(blocking=False): 439 ↛ 440line 439 didn't jump to line 440 because the condition on line 439 was never true
440 logger.exception('Could not acquire lock for reload_plugins')
441 return
443 # Reset the loading error state
444 if clear_errors:
445 self.errors = {}
447 try:
448 plugin_on_startup = get_global_setting(
449 'PLUGIN_ON_STARTUP', create=False, cache=False
450 )
451 except Exception:
452 plugin_on_startup = False
454 try:
455 logger.info(
456 'Plugin Registry: Reloading plugins - Force: %s, Full: %s, Collect: %s',
457 force_reload,
458 full_reload,
459 collect,
460 )
462 # If we are in a container environment, reload the entire plugins file
463 if collect:
464 logger.info('Collecting plugins')
466 if plugin_on_startup and not settings.PLUGINS_INSTALL_DISABLED: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true
467 self.install_plugin_file()
469 self.plugin_modules = self.collect_plugins()
471 self.plugins_loaded = False
472 self._unload_plugins(force_reload=force_reload)
473 self.plugins_loaded = True
474 self._load_plugins(full_reload=full_reload, _internal=_internal)
476 self.update_plugin_hash()
477 logger.info('Plugin Registry: Loaded %s plugins', len(self.plugins))
479 # Ensure that each loaded plugin has a valid configuration object in the database
480 for plugin in self.plugins.values():
481 if config := self.get_plugin_config(plugin.slug): 481 ↛ 480line 481 didn't jump to line 480 because the condition on line 481 was always true
482 # Ensure mandatory plugins are marked as active
483 if config.is_mandatory() and not config.active: 483 ↛ 484line 483 didn't jump to line 484 because the condition on line 483 was never true
484 config.active = True
486 try:
487 config.save(no_reload=True)
488 except (OperationalError, ProgrammingError):
489 # Database is not ready, cannot save config
490 logger.warning(
491 "Database not ready - cannot set mandatory flag for plugin '%s'",
492 plugin.slug,
493 )
495 except Exception as e:
496 logger.exception('Unexpected error during plugin reload: %s', e)
497 log_error('reload_plugins', scope='plugins')
499 finally:
500 # Ensure the lock is released always
501 self.loading_lock.release()
503 def plugin_dirs(self) -> list[str]:
504 """Construct a list of directories from where plugins can be loaded."""
505 # Builtin plugins are *always* loaded
506 dirs = ['plugin.builtin']
508 if settings.PLUGINS_ENABLED: 508 ↛ 511line 508 didn't jump to line 511 because the condition on line 508 was never true
509 # Any 'external' plugins are only loaded if PLUGINS_ENABLED is set to True
511 if settings.TESTING or settings.DEBUG:
512 # If in TEST or DEBUG mode, load plugins from the 'samples' directory
513 dirs.append('plugin.samples')
515 if settings.TESTING:
516 dirs.append('plugin.testing')
518 if settings.TESTING:
519 custom_dirs = os.getenv('INVENTREE_PLUGIN_TEST_DIR', None)
520 else: # pragma: no cover
521 custom_dirs = get_plugin_dir()
523 # Load from user specified directories (unless in testing mode)
524 dirs.append('plugins')
526 if custom_dirs is not None:
527 # Allow multiple plugin directories to be specified
528 for pd_text in custom_dirs.split(','):
529 pd = Path(pd_text.strip()).absolute()
531 # Attempt to create the directory if it does not already exist
532 if not pd.exists():
533 try:
534 pd.mkdir(exist_ok=True)
535 except Exception as e: # pragma: no cover
536 logger.exception(
537 "Could not create plugin directory '%s'", pd
538 )
539 log_registry_error(e, 'plugin_dirs')
540 continue
542 # Ensure the directory has an __init__.py file
543 init_filename = pd.joinpath('__init__.py')
545 if not init_filename.exists():
546 try:
547 init_filename.write_text('# InvenTree plugin directory\n')
548 except Exception: # pragma: no cover
549 logger.exception(
550 "Could not create file '%s'", init_filename
551 )
552 log_error('plugin_dirs', scope='plugins')
553 continue
555 # By this point, we have confirmed that the directory at least exists
556 if pd.exists() and pd.is_dir():
557 # Convert to python dot-path
558 if pd.is_relative_to(settings.BASE_DIR):
559 pd_path = '.'.join(pd.relative_to(settings.BASE_DIR).parts)
560 else:
561 pd_path = str(pd)
563 # Add path
564 dirs.append(pd_path)
565 logger.info("Added plugin directory: '%s' as '%s'", pd, pd_path)
567 return dirs
569 def collect_plugins(self):
570 """Collect plugins from all possible ways of loading. Returned as list."""
571 collected_plugins = []
573 # Collect plugins from paths
574 for plugin_dir in self.plugin_dirs():
575 logger.debug("Loading plugins from directory '%s'", plugin_dir)
577 parent_path = None
578 parent_obj = Path(plugin_dir)
580 # If a "path" is provided, some special handling is required
581 if parent_obj.name is not plugin_dir and len(parent_obj.parts) > 1: 581 ↛ 583line 581 didn't jump to line 583 because the condition on line 581 was never true
582 # Ensure PosixPath object is converted to a string, before passing to get_plugins
583 parent_path = str(parent_obj.parent)
584 plugin_dir = parent_obj.name
586 # Gather Modules
587 if parent_path: 587 ↛ 589line 587 didn't jump to line 589 because the condition on line 587 was never true
588 # On python 3.12 use new loader method
589 if sys.version_info < (3, 12):
590 raw_module = _load_source(
591 plugin_dir, str(parent_obj.joinpath('__init__.py'))
592 )
593 else:
594 raw_module = SourceFileLoader(
595 plugin_dir, str(parent_obj.joinpath('__init__.py'))
596 ).load_module()
597 else:
598 raw_module = importlib.import_module(plugin_dir)
600 modules = get_plugins(raw_module, InvenTreePlugin, path=parent_path)
602 for item in modules or []:
603 collected_plugins.append(item)
605 # From this point any plugins are considered "external" and only loaded if plugins are explicitly enabled
606 if settings.PLUGINS_ENABLED: 606 ↛ 608line 606 didn't jump to line 608 because the condition on line 606 was never true
607 # Check if not running in testing mode and apps should be loaded from hooks
608 if (not settings.PLUGIN_TESTING) or (
609 settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP
610 ):
611 # Collect plugins from setup entry points
612 for entry in get_entrypoints():
613 logger.debug(
614 "Loading plugin '%s' from module '%s'", entry.name, entry.module
615 )
617 try:
618 plugin = entry.load()
619 plugin.is_package = True
620 plugin.package_name = getattr(entry.dist, 'name', None)
621 plugin._get_package_metadata()
622 collected_plugins.append(plugin)
623 except Exception as error: # pragma: no cover
624 handle_error(error, do_raise=False, log_name='discovery')
626 # Log collected plugins
627 logger.info('Collected %s plugins', len(collected_plugins))
628 logger.debug(', '.join([a.__module__ for a in collected_plugins]))
630 return collected_plugins
632 def discover_mixins(self):
633 """Discover all mixins from plugins and register them."""
634 collected_mixins = {}
636 for plg in self.plugins.values():
637 collected_mixins.update(plg.get_registered_mixins())
639 self.mixin_modules = collected_mixins
641 def install_plugin_file(self):
642 """Make sure all plugins are installed in the current environment."""
643 from plugin.installer import install_plugins_file, plugins_file_hash
645 file_hash = plugins_file_hash()
647 if file_hash != settings.PLUGIN_FILE_HASH: 647 ↛ exitline 647 didn't return from function 'install_plugin_file' because the condition on line 647 was always true
648 install_plugins_file()
649 settings.PLUGIN_FILE_HASH = file_hash
651 # endregion
653 # region general internal loading / activating / deactivating / unloading
654 def _init_plugin(self, plugin, configs: dict):
655 """Initialise a single plugin.
657 Args:
658 plugin: Plugin module
659 configs: Plugin configuration dictionary
660 force_reload (bool, optional): Force reload of plugin. Defaults to False.
661 """
662 from InvenTree import version
664 def safe_reference(plugin, key: str, active: bool = True):
665 """Safe reference to plugin dicts."""
666 if active:
667 self.plugins[key] = plugin
668 else:
669 # Deactivate plugin in db (if currently set as active)
670 if not settings.PLUGIN_TESTING and plugin.db.active: # pragma: no cover 670 ↛ 671line 670 didn't jump to line 671 because the condition on line 670 was never true
671 plugin.db.active = False
672 plugin.db.save(no_reload=True)
673 self.plugins_inactive[key] = plugin.db
674 self.plugins_full[key] = plugin
676 # These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
677 plg_name = plugin.NAME
678 plg_key = slugify(
679 plugin.SLUG if getattr(plugin, 'SLUG', None) else plg_name
680 ) # keys are slugs!
682 logger.info("Loading plugin '%s'", plg_key)
684 if plg_key in configs:
685 plg_db = configs[plg_key]
687 # Handle edge case where PluginConfig has been created without a valid name
688 if plg_name and plg_db and plg_db.name != plg_name: 688 ↛ 689line 688 didn't jump to line 689 because the condition on line 688 was never true
689 plg_db.name = plg_name
690 plg_db.save()
691 else:
692 plg_db = self.get_plugin_config(plg_key, plg_name)
694 plugin.db = plg_db
696 # Check if this is a 'builtin' plugin
697 builtin = plg_db.is_builtin() if plg_db else plugin.check_is_builtin()
698 sample = plg_db.is_sample() if plg_db else plugin.check_is_sample()
700 package_name = None
702 # Extract plugin package name
703 if getattr(plugin, 'is_package', False): 703 ↛ 704line 703 didn't jump to line 704 because the condition on line 703 was never true
704 package_name = getattr(plugin, 'package_name', None)
706 # Auto-enable default builtin plugins
707 if plg_db and plg_db.is_mandatory():
708 if not plg_db.active: 708 ↛ 709line 708 didn't jump to line 709 because the condition on line 708 was never true
709 plg_db.active = True
710 plg_db.save()
712 # Save the package_name attribute to the plugin
713 if plg_db.package_name != package_name: 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true
714 plg_db.package_name = package_name
715 plg_db.save()
717 # Check if this plugin is considered 'mandatory'
718 mandatory = (
719 plg_key in self.MANDATORY_PLUGINS or plg_key in settings.PLUGINS_MANDATORY
720 )
722 # Determine if this plugin should be loaded:
723 # - If PLUGIN_TESTING is enabled
724 # - If this is a 'mandatory' plugin
725 # - If this plugin has been explicitly enabled by the user
726 if settings.PLUGIN_TESTING or mandatory or (plg_db and plg_db.active):
727 # Initialize package - we can be sure that an admin has activated the plugin
728 logger.debug('Loading plugin `%s`', plg_name)
730 # If this is a third-party plugin, reload the source module
731 # This is required to ensure that separate processes are using the same code
732 if not builtin and not sample: 732 ↛ 754line 732 didn't jump to line 754 because the condition on line 732 was always true
733 plugin_name = plugin.__name__
734 module_name = plugin.__module__
736 if plugin_module := sys.modules.get(module_name): 736 ↛ 754line 736 didn't jump to line 754 because the condition on line 736 was always true
737 logger.debug('Reloading plugin `%s`', plg_name)
738 # Reload the module
739 try:
740 importlib.reload(plugin_module)
741 plugin = getattr(plugin_module, plugin_name)
742 except ModuleNotFoundError:
743 # No module found - try to import it directly
744 try:
745 raw_module = _load_source(
746 module_name, plugin_module.__file__
747 )
748 plugin = getattr(raw_module, plugin_name)
749 except Exception:
750 pass
751 except Exception:
752 logger.exception('Failed to reload plugin `%s`', plg_name)
754 try:
755 t_start = time.time()
756 plg_i: InvenTreePlugin = plugin()
757 dt = time.time() - t_start
758 logger.debug('Loaded plugin `%s` in %.3fs', plg_name, dt)
760 if mandatory and not plg_db.active: # pragma: no cover 760 ↛ 762line 760 didn't jump to line 762 because the condition on line 760 was never true
761 # If this is a mandatory plugin, ensure it is marked as active
762 logger.info(
763 'Plugin `%s` is a mandatory plugin - activating', plg_name
764 )
765 plg_db.active = True
766 plg_db.save()
768 except ModuleNotFoundError as e:
769 raise e
770 except Exception as error:
771 handle_error(
772 error, log_name=f'{plg_name}:init_plugin'
773 ) # log error and raise it -> disable plugin
775 logger.warning('Plugin `%s` could not be loaded', plg_name)
777 # Safe extra attributes
778 plg_i.is_package = getattr(plg_i, 'is_package', False)
780 plg_i.pk = plg_db.pk if plg_db else None
781 plg_i.db = plg_db
783 # Run version check for plugin
784 if (plg_i.MIN_VERSION or plg_i.MAX_VERSION) and not plg_i.check_version(): 784 ↛ 786line 784 didn't jump to line 786 because the condition on line 784 was never true
785 # Disable plugin
786 safe_reference(plugin=plg_i, key=plg_key, active=False)
788 p = plg_name
789 v = version.inventreeVersion()
790 _msg = _(
791 f"Plugin '{p}' is not compatible with the current InvenTree version {v}"
792 )
793 if v := plg_i.MIN_VERSION:
794 _msg += _(f'Plugin requires at least version {v}') # ty:ignore[unsupported-operator]
795 if v := plg_i.MAX_VERSION:
796 _msg += _(f'Plugin requires at most version {v}') # ty:ignore[unsupported-operator]
797 # Log to error stack
798 log_registry_error(_msg, reference=f'{p}:init_plugin')
799 else:
800 safe_reference(plugin=plg_i, key=plg_key)
801 else: # pragma: no cover
802 safe_reference(plugin=plugin, key=plg_key, active=False)
804 def _init_plugins(self):
805 """Initialise all found plugins.
807 Args:
808 disabled (str, optional): Loading path of disabled app. Defaults to None.
810 Raises:
811 error: IntegrationPluginError
812 """
813 # Imports need to be in this level to prevent early db model imports
814 from plugin.models import PluginConfig
816 logger.debug('Starting plugin initialization')
818 # Fetch and cache list of existing plugin configuration instances
819 plugin_configs = {cfg.key: cfg for cfg in PluginConfig.objects.all()}
821 # Initialize plugins
822 for plg in self.plugin_modules:
823 # Attempt to load each individual plugin
824 attempts = settings.PLUGIN_RETRY
826 while attempts > 0: 826 ↛ 822line 826 didn't jump to line 822 because the condition on line 826 was always true
827 attempts -= 1
829 try:
830 self._init_plugin(plg, plugin_configs)
831 break
832 except Exception as error:
833 # Handle the error, log it and try again
834 if attempts == 0:
835 handle_error(error, log_name='init_plugins', do_raise=True)
837 logger.exception(
838 '[PLUGIN] Encountered an error with %s:\n%s',
839 getattr(error, 'path', None),
840 error,
841 )
843 logger.debug('Finished plugin initialization')
845 def __get_mixin_order(self):
846 """Returns a list of mixin classes, in the order that they should be activated."""
847 # Preset list of mixins
848 order = self.DEFAULT_MIXIN_ORDER
850 # Append mixins that are not defined in the default list
851 order += [
852 m.get('cls')
853 for m in self.mixin_modules.values()
854 if m.get('cls') not in order
855 ]
857 # Final list of mixins
858 return order
860 def _activate_plugins(
861 self,
862 force_reload=False,
863 full_reload: bool = False,
864 _internal: Optional[list] = None,
865 ):
866 """Run activation functions for all plugins.
868 Args:
869 force_reload (bool, optional): Also reload base apps. Defaults to False.
870 full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
871 """
872 # Collect mixins
873 self.discover_mixins()
875 # Activate integrations
876 plugins = self.plugins.items()
877 logger.info('Found %s active plugins', len(plugins))
879 for mixin in self.__get_mixin_order():
880 if hasattr(mixin, '_activate_mixin'):
881 mixin._activate_mixin(
882 self,
883 plugins,
884 force_reload=force_reload,
885 full_reload=full_reload,
886 _internal=_internal,
887 )
889 logger.debug('Done activating')
891 def _deactivate_plugins(self, force_reload: bool = False):
892 """Run deactivation functions for all plugins.
894 Args:
895 force_reload (bool, optional): Also reload base apps. Defaults to False.
896 """
897 for mixin in reversed(self.__get_mixin_order()):
898 if hasattr(mixin, '_deactivate_mixin'):
899 mixin._deactivate_mixin(self, force_reload=force_reload)
901 logger.debug('Finished deactivating plugins')
903 # endregion
905 # region mixin specific loading ...
906 def _try_reload(self, cmd, *args, **kwargs):
907 """Wrapper to try reloading the apps.
909 Throws an custom error that gets handled by the loading function.
910 """
911 try:
912 cmd(*args, **kwargs)
913 return True, []
914 except Exception as error: # pragma: no cover
915 handle_error(error, do_raise=False)
917 def _reload_apps(
918 self,
919 force_reload: bool = False,
920 full_reload: bool = False,
921 _internal: Optional[list] = None,
922 ):
923 """Internal: reload apps using django internal functions.
925 Args:
926 force_reload (bool, optional): Also reload base apps. Defaults to False.
927 full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
928 _internal (list, optional): Internal use only (for testing). Defaults to None.
929 """
930 loadable_apps = settings.INSTALLED_APPS
931 if _internal: 931 ↛ 932line 931 didn't jump to line 932 because the condition on line 931 was never true
932 loadable_apps += _internal
934 if force_reload:
935 # we can not use the built in functions as we need to brute force the registry
936 apps.app_configs = OrderedDict()
937 apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
938 apps.clear_cache()
939 self._try_reload(apps.populate, loadable_apps)
940 else:
941 self._try_reload(apps.set_installed_apps, loadable_apps)
943 def _clean_installed_apps(self):
944 for plugin in self.installed_apps: 944 ↛ 945line 944 didn't jump to line 945 because the loop on line 944 never started
945 if plugin in settings.INSTALLED_APPS:
946 settings.INSTALLED_APPS.remove(plugin)
948 self.installed_apps = []
950 def _clean_registry(self):
951 """Remove all plugins from registry."""
952 self.plugins: dict[str, InvenTreePlugin] = {}
953 self.plugins_inactive: dict[str, InvenTreePlugin] = {}
954 self.plugins_full: dict[str, InvenTreePlugin] = {}
956 def _update_urls(self):
957 """Due to the order in which plugins are loaded, the patterns in urls.py may be out of date.
959 This function updates the patterns in urls.py to ensure that the correct patterns are loaded,
960 and then refreshes the django url cache.
962 Note that we also have to refresh the admin site URLS,
963 as any custom AppMixin plugins require admin integration
964 """
965 from InvenTree.urls import urlpatterns
966 from plugin.urls import get_plugin_urls
968 for index, url in enumerate(urlpatterns):
969 app_name = getattr(url, 'app_name', None)
971 admin_url = settings.INVENTREE_ADMIN_URL
973 if app_name == 'admin':
974 urlpatterns[index] = path(
975 f'{admin_url}/', admin.site.urls, name='inventree-admin'
976 )
978 if app_name == 'plugin': 978 ↛ 979line 978 didn't jump to line 979 because the condition on line 978 was never true
979 urlpatterns[index] = get_plugin_urls()
981 # Refresh the URL cache
982 clear_url_caches()
984 # endregion
986 # region plugin registry hash calculations
987 def update_plugin_hash(self):
988 """When the state of the plugin registry changes, update the hash."""
989 self.registry_hash = self.calculate_plugin_hash()
991 try:
992 old_hash = get_global_setting(
993 '_PLUGIN_REGISTRY_HASH', '', create=False, cache=False
994 )
995 except Exception:
996 old_hash = ''
998 if old_hash != self.registry_hash:
999 try:
1000 logger.info('Updating plugin registry hash: %s', self.registry_hash)
1002 set_global_setting(
1003 '_PLUGIN_REGISTRY_HASH', self.registry_hash, change_user=None
1004 )
1005 except (OperationalError, ProgrammingError):
1006 # Exception if the database has not been migrated yet, or is not ready
1007 pass
1008 except Exception as exc:
1009 # Some other exception, we want to know about it
1010 logger.exception('Failed to update plugin registry hash: %s', exc)
1012 def plugin_settings_keys(self):
1013 """A list of keys which are used to store plugin settings."""
1014 return [
1015 'ENABLE_PLUGINS_APP',
1016 'ENABLE_PLUGINS_EVENTS',
1017 'ENABLE_PLUGINS_INTERFACE',
1018 'ENABLE_PLUGINS_NAVIGATION',
1019 'ENABLE_PLUGINS_SCHEDULE',
1020 'ENABLE_PLUGINS_URL',
1021 'ENABLE_PLUGINS_MAILS',
1022 ]
1024 def calculate_plugin_hash(self):
1025 """Calculate a 'hash' value for the current registry.
1027 This is used to detect changes in the plugin registry,
1028 and to inform other processes that the plugin registry has changed
1029 """
1030 from hashlib import md5
1032 data = md5()
1034 # Hash for all loaded plugins
1035 # Note: Sort by slug, so the hash is independent of discovery order.
1036 # Different processes can discover the same plugins in a different
1037 # order, and the hash must represent the registry *state*, not the
1038 # iteration order of any particular process.
1039 for slug, plug in sorted(self.plugins.items(), key=lambda item: item[0]):
1040 data.update(str(slug).encode())
1041 data.update(str(plug.name).encode())
1042 data.update(str(plug.version).encode())
1043 data.update(str(plug.is_active()).encode())
1045 for k in self.plugin_settings_keys():
1046 try:
1047 val = get_global_setting(k, create=False)
1048 msg = f'{k}-{val}'
1050 data.update(msg.encode())
1051 except Exception:
1052 pass
1054 return str(data.hexdigest())
1056 @registry_entrypoint(default_value=False, check_reload=False)
1057 def check_reload(self):
1058 """Determine if the registry needs to be reloaded.
1060 Returns True if the registry has changed and was reloaded.
1061 """
1062 if settings.TESTING and not settings.PLUGIN_TESTING_RELOAD: 1062 ↛ 1064line 1062 didn't jump to line 1064 because the condition on line 1062 was never true
1063 # Skip if running during unit testing
1064 return False
1066 if not InvenTree.ready.canAppAccessDatabase( 1066 ↛ 1070line 1066 didn't jump to line 1070 because the condition on line 1066 was never true
1067 allow_shell=True, allow_test=settings.PLUGIN_TESTING_RELOAD
1068 ):
1069 # Skip check if database cannot be accessed
1070 return False
1072 if InvenTree.cache.get_session_cache('plugin_registry_checked'):
1073 # Return early if the registry has already been checked (for this request)
1074 return False
1076 InvenTree.cache.set_session_cache('plugin_registry_checked', True)
1078 logger.debug('Checking plugin registry hash')
1080 # If not already cached, calculate the hash
1081 if not self.registry_hash:
1082 self.registry_hash = self.calculate_plugin_hash()
1084 try:
1085 reg_hash = get_global_setting('_PLUGIN_REGISTRY_HASH', '', create=False)
1086 except Exception as exc:
1087 logger.exception('Failed to retrieve plugin registry hash: %s', exc)
1088 return False
1090 if reg_hash and reg_hash != self.registry_hash: 1090 ↛ 1091line 1090 didn't jump to line 1091 because the condition on line 1090 was never true
1091 logger.info('Plugin registry hash has changed - reloading')
1092 self.reload_plugins(full_reload=True, force_reload=True, collect=True)
1093 return True
1094 return False
1096 # endregion
1099registry: PluginsRegistry = PluginsRegistry()
1102def call_plugin_function(plugin_name: str, function_name: str, *args, **kwargs):
1103 """Global helper function to call a specific member function of a plugin."""
1104 return registry.call_plugin_function(plugin_name, function_name, *args, **kwargs)
1107def _load_source(modname, filename):
1108 """Helper function to replace deprecated & removed imp.load_source.
1110 See https://docs.python.org/3/whatsnew/3.12.html#imp
1111 """
1112 if modname in sys.modules:
1113 del sys.modules[modname]
1115 # loader = importlib.machinery.SourceFileLoader(modname, filename)
1116 spec = importlib.util.spec_from_file_location(modname, filename) # , loader=loader)
1117 if spec is None:
1118 raise ImportError(f"Cannot find module '{modname}'") # pragma: no cover
1119 module = importlib.util.module_from_spec(spec)
1121 sys.modules[module.__name__] = module
1123 loader = spec.loader
1124 if loader is not None:
1125 loader.exec_module(module)
1127 return module