mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Plugin sequence fix (#10057)
* Fix error message * Add registry ready flag * Add is_ready func * Cleaner init code * Protect against plugin access until ready * Add decorator for registry entrypoint funcs * Tweak return value * Only add plugin URLs if registry is ready * Fix load logic * Refactor @registry_entrypoint decorator * Tweak * Typing * Reimplement is_ready property
This commit is contained in:
		| @@ -9,7 +9,7 @@ from django.conf import settings | ||||
| from django.contrib.auth import get_user_model | ||||
| from django.core.exceptions import AppRegistryNotReady | ||||
| from django.db import transaction | ||||
| from django.db.utils import IntegrityError, OperationalError | ||||
| from django.db.utils import IntegrityError | ||||
|  | ||||
| import structlog | ||||
| from allauth.socialaccount.signals import social_account_updated | ||||
| @@ -65,7 +65,8 @@ class InvenTreeConfig(AppConfig): | ||||
|             self.start_background_tasks() | ||||
|  | ||||
|             if not InvenTree.ready.isInTestMode():  # pragma: no cover | ||||
|                 self.update_exchange_rates() | ||||
|                 # Update exchange rates | ||||
|                 InvenTree.tasks.offload_task(InvenTree.tasks.update_exchange_rates) | ||||
|                 # Let the background worker check for migrations | ||||
|                 InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations) | ||||
|  | ||||
| @@ -179,6 +180,8 @@ class InvenTreeConfig(AppConfig): | ||||
|                 InvenTree.tasks.offload_task( | ||||
|                     InvenTree.tasks.heartbeat, force_async=True, group='heartbeat' | ||||
|                 ) | ||||
|         except AppRegistryNotReady:  # pragma: no cover | ||||
|             pass | ||||
|         except Exception: | ||||
|             pass | ||||
|  | ||||
| @@ -194,63 +197,6 @@ class InvenTreeConfig(AppConfig): | ||||
|                 except Exception as e:  # pragma: no cover | ||||
|                     logger.exception('Error loading tasks for %s: %s', app_name, e) | ||||
|  | ||||
|     def update_exchange_rates(self):  # pragma: no cover | ||||
|         """Update exchange rates each time the server is started. | ||||
|  | ||||
|         Only runs *if*: | ||||
|         a) Have not been updated recently (one day or less) | ||||
|         b) The base exchange rate has been altered | ||||
|         """ | ||||
|         try: | ||||
|             from djmoney.contrib.exchange.models import ExchangeBackend | ||||
|  | ||||
|             from common.currency import currency_code_default | ||||
|             from InvenTree.tasks import update_exchange_rates | ||||
|         except AppRegistryNotReady:  # pragma: no cover | ||||
|             pass | ||||
|  | ||||
|         base_currency = currency_code_default() | ||||
|  | ||||
|         update = False | ||||
|  | ||||
|         try: | ||||
|             backend = ExchangeBackend.objects.filter(name='InvenTreeExchange') | ||||
|  | ||||
|             if backend.exists(): | ||||
|                 backend = backend.first() | ||||
|  | ||||
|                 last_update = backend.last_update | ||||
|  | ||||
|                 if last_update is None: | ||||
|                     # Never been updated | ||||
|                     logger.info('Exchange backend has never been updated') | ||||
|                     update = True | ||||
|  | ||||
|                 # Backend currency has changed? | ||||
|                 if base_currency != backend.base_currency: | ||||
|                     logger.info( | ||||
|                         'Base currency changed from %s to %s', | ||||
|                         backend.base_currency, | ||||
|                         base_currency, | ||||
|                     ) | ||||
|                     update = True | ||||
|  | ||||
|         except ExchangeBackend.DoesNotExist: | ||||
|             logger.info('Exchange backend not found - updating') | ||||
|             update = True | ||||
|  | ||||
|         except Exception: | ||||
|             # Some other error - potentially the tables are not ready yet | ||||
|             return | ||||
|  | ||||
|         if update: | ||||
|             try: | ||||
|                 update_exchange_rates() | ||||
|             except OperationalError: | ||||
|                 logger.warning('Could not update exchange rates - database not ready') | ||||
|             except Exception as e: | ||||
|                 logger.exception('Error updating exchange rates: %s (%s)', e, type(e)) | ||||
|  | ||||
|     def update_site_url(self): | ||||
|         """Update the site URL setting. | ||||
|  | ||||
|   | ||||
| @@ -15,12 +15,14 @@ import InvenTree.helpers | ||||
| logger = structlog.get_logger('inventree') | ||||
|  | ||||
|  | ||||
| def currency_code_default(): | ||||
| def currency_code_default(create: bool = True): | ||||
|     """Returns the default currency code (or USD if not specified).""" | ||||
|     from common.settings import get_global_setting | ||||
|  | ||||
|     try: | ||||
|         code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True) | ||||
|         code = get_global_setting( | ||||
|             'INVENTREE_DEFAULT_CURRENCY', create=create, cache=True | ||||
|         ) | ||||
|     except Exception:  # pragma: no cover | ||||
|         # Database may not yet be ready, no need to throw an error here | ||||
|         code = '' | ||||
|   | ||||
| @@ -32,23 +32,11 @@ class PluginAppConfig(AppConfig): | ||||
|         else: | ||||
|             logger.info('Loading InvenTree plugins') | ||||
|  | ||||
|             if not registry.is_loading: | ||||
|                 # this is the first startup | ||||
|                 try: | ||||
|                     from common.models import InvenTreeSetting | ||||
|  | ||||
|                     if InvenTreeSetting.get_setting( | ||||
|                         'PLUGIN_ON_STARTUP', create=False, cache=False | ||||
|                     ): | ||||
|                         # make sure all plugins are installed | ||||
|                         registry.install_plugin_file() | ||||
|                 except Exception:  # pragma: no cover | ||||
|                     pass | ||||
|  | ||||
|                 # Perform a full reload of the plugin registry | ||||
|                 registry.reload_plugins( | ||||
|                     full_reload=True, force_reload=True, collect=True | ||||
|                 ) | ||||
|             if not registry.is_ready: | ||||
|                 # Mark the registry as ready | ||||
|                 # This ensures that other apps cannot access the registry before it is fully initialized | ||||
|                 logger.info('Plugin registry is ready - performing initial load') | ||||
|                 registry.set_ready() | ||||
|  | ||||
|                 # drop out of maintenance | ||||
|                 # makes sure we did not have an error in reloading and maintenance is still active | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
| - Manages setup and teardown of plugin class instances | ||||
| """ | ||||
|  | ||||
| import functools | ||||
| import importlib | ||||
| import importlib.util | ||||
| import os | ||||
| @@ -43,6 +44,37 @@ from .plugin import InvenTreePlugin | ||||
| logger = structlog.get_logger('inventree') | ||||
|  | ||||
|  | ||||
| def registry_entrypoint(check_reload: bool = True, default_value: Any = None) -> Any: | ||||
|     """Function decorator for registry entrypoints methods. | ||||
|  | ||||
|     - Ensure that the registry is ready before calling the method. | ||||
|     - Check if the registry needs to be reloaded. | ||||
|     """ | ||||
|  | ||||
|     def decorator(method): | ||||
|         """Decorator to ensure registry is ready before calling the method.""" | ||||
|  | ||||
|         @functools.wraps(method) | ||||
|         def wrapper(self, *args, **kwargs): | ||||
|             """Wrapper function to ensure registry is ready.""" | ||||
|             if not self.ready: | ||||
|                 logger.warning( | ||||
|                     "Plugin registry is not ready - cannot call method '%s'", | ||||
|                     method.__name__, | ||||
|                 ) | ||||
|                 return default_value | ||||
|  | ||||
|             # Check if the registry needs to be reloaded | ||||
|             if check_reload: | ||||
|                 self.check_reload() | ||||
|  | ||||
|             return method(self, *args, **kwargs) | ||||
|  | ||||
|         return wrapper | ||||
|  | ||||
|     return decorator | ||||
|  | ||||
|  | ||||
| class PluginsRegistry: | ||||
|     """The PluginsRegistry class.""" | ||||
|  | ||||
| @@ -68,6 +100,8 @@ class PluginsRegistry: | ||||
|         'parameter-exporter', | ||||
|     ] | ||||
|  | ||||
|     ready: bool | ||||
|  | ||||
|     def __init__(self) -> None: | ||||
|         """Initialize registry. | ||||
|  | ||||
| @@ -82,6 +116,8 @@ class PluginsRegistry: | ||||
|             str, InvenTreePlugin | ||||
|         ] = {}  # List of all plugin instances | ||||
|  | ||||
|         self.ready = False  # Marks if the registry is ready to be used | ||||
|  | ||||
|         # Keep an internal hash of the plugin registry state | ||||
|         self.registry_hash = None | ||||
|  | ||||
| @@ -100,11 +136,35 @@ class PluginsRegistry: | ||||
|  | ||||
|         self.installed_apps = []  # Holds all added plugin_paths | ||||
|  | ||||
|     def set_ready(self): | ||||
|         """Set the registry as ready to be used. | ||||
|  | ||||
|         This method should only be called once per application start, | ||||
|         after all apps have been loaded and the registry is fully initialized. | ||||
|         """ | ||||
|         from common.models import InvenTreeSetting | ||||
|  | ||||
|         self.ready = True | ||||
|  | ||||
|         # Install plugins from file (if required) | ||||
|         if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False, cache=False): | ||||
|             # make sure all plugins are installed | ||||
|             registry.install_plugin_file() | ||||
|  | ||||
|         # Perform initial plugin discovery | ||||
|         self.reload_plugins(full_reload=True, force_reload=True, collect=True) | ||||
|  | ||||
|     @property | ||||
|     def is_ready(self) -> bool: | ||||
|         """Return True if the plugin registry is ready to be used.""" | ||||
|         return self.ready | ||||
|  | ||||
|     @property | ||||
|     def is_loading(self) -> bool: | ||||
|         """Return True if the plugin registry is currently loading.""" | ||||
|         return self.loading_lock.locked() | ||||
|  | ||||
|     @registry_entrypoint() | ||||
|     def get_plugin( | ||||
|         self, slug: str, active: bool = True, with_mixin: Optional[str] = None | ||||
|     ) -> InvenTreePlugin: | ||||
| @@ -118,9 +178,6 @@ class PluginsRegistry: | ||||
|         Returns: | ||||
|             InvenTreePlugin or None: The plugin instance if found, otherwise None. | ||||
|         """ | ||||
|         # Check if the registry needs to be reloaded | ||||
|         self.check_reload() | ||||
|  | ||||
|         if slug not in self.plugins: | ||||
|             logger.warning("Plugin registry has no record of plugin '%s'", slug) | ||||
|             return None | ||||
| @@ -169,6 +226,7 @@ class PluginsRegistry: | ||||
|  | ||||
|         return cfg | ||||
|  | ||||
|     @registry_entrypoint() | ||||
|     def set_plugin_state(self, slug: str, state: bool): | ||||
|         """Set the state(active/inactive) of a plugin. | ||||
|  | ||||
| @@ -176,9 +234,6 @@ class PluginsRegistry: | ||||
|             slug (str): Plugin slug | ||||
|             state (bool): Plugin state - true = active, false = inactive | ||||
|         """ | ||||
|         # Check if the registry needs to be reloaded | ||||
|         self.check_reload() | ||||
|  | ||||
|         if slug not in self.plugins_full: | ||||
|             logger.warning("Plugin registry has no record of plugin '%s'", slug) | ||||
|             return | ||||
| @@ -190,6 +245,7 @@ class PluginsRegistry: | ||||
|         # Update the registry hash value | ||||
|         self.update_plugin_hash() | ||||
|  | ||||
|     @registry_entrypoint() | ||||
|     def call_plugin_function(self, slug: str, func: str, *args, **kwargs): | ||||
|         """Call a member function (named by 'func') of the plugin named by 'slug'. | ||||
|  | ||||
| @@ -198,9 +254,6 @@ class PluginsRegistry: | ||||
|  | ||||
|         Instead, any error messages are returned to the worker. | ||||
|         """ | ||||
|         # Check if the registry needs to be reloaded | ||||
|         self.check_reload() | ||||
|  | ||||
|         raise_error = kwargs.pop('raise_error', True) | ||||
|  | ||||
|         plugin = self.get_plugin(slug) | ||||
| @@ -220,6 +273,8 @@ class PluginsRegistry: | ||||
|         return plugin_func(*args, **kwargs) | ||||
|  | ||||
|     # region registry functions | ||||
|  | ||||
|     @registry_entrypoint(default_value=[]) | ||||
|     def with_mixin( | ||||
|         self, mixin: str, active: bool = True, builtin: Optional[bool] = None | ||||
|     ) -> list[InvenTreePlugin]: | ||||
| @@ -230,9 +285,6 @@ class PluginsRegistry: | ||||
|             active (bool, optional): Filter by 'active' status of plugin. Defaults to True. | ||||
|             builtin (bool, optional): Filter by 'builtin' status of plugin. Defaults to None. | ||||
|         """ | ||||
|         # Check if the registry needs to be loaded | ||||
|         self.check_reload() | ||||
|  | ||||
|         mixin = str(mixin).lower().strip() | ||||
|  | ||||
|         result = [] | ||||
| @@ -304,6 +356,7 @@ class PluginsRegistry: | ||||
|  | ||||
|         logger.info('Finished unloading plugins') | ||||
|  | ||||
|     @registry_entrypoint(check_reload=False) | ||||
|     def reload_plugins( | ||||
|         self, | ||||
|         full_reload: bool = False, | ||||
| @@ -369,7 +422,7 @@ class PluginsRegistry: | ||||
|             logger.info('Plugin Registry: Loaded %s plugins', len(self.plugins)) | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.exception('Expected error during plugin reload: %s', e) | ||||
|             logger.exception('Unexpected error during plugin reload: %s', e) | ||||
|             log_error('reload_plugins', scope='plugins') | ||||
|  | ||||
|         finally: | ||||
| @@ -900,6 +953,7 @@ class PluginsRegistry: | ||||
|  | ||||
|         return str(data.hexdigest()) | ||||
|  | ||||
|     @registry_entrypoint(default_value=False, check_reload=False) | ||||
|     def check_reload(self): | ||||
|         """Determine if the registry needs to be reloaded. | ||||
|  | ||||
|   | ||||
| @@ -18,28 +18,32 @@ def get_plugin_urls(): | ||||
|  | ||||
|     urls = [] | ||||
|  | ||||
|     if get_global_setting('ENABLE_PLUGINS_URL', False) or settings.PLUGIN_TESTING_SETUP: | ||||
|         for plugin in registry.with_mixin(PluginMixinEnum.URLS): | ||||
|             try: | ||||
|                 if plugin_urls := plugin.urlpatterns: | ||||
|                     # Check if the plugin has a custom URL pattern | ||||
|                     for url in plugin_urls: | ||||
|                         # Attempt to resolve against the URL pattern as a validation check | ||||
|                         try: | ||||
|                             url.resolve('') | ||||
|                         except Resolver404: | ||||
|                             pass | ||||
|     if registry.is_ready: | ||||
|         if ( | ||||
|             get_global_setting('ENABLE_PLUGINS_URL', False) | ||||
|             or settings.PLUGIN_TESTING_SETUP | ||||
|         ): | ||||
|             for plugin in registry.with_mixin(PluginMixinEnum.URLS): | ||||
|                 try: | ||||
|                     if plugin_urls := plugin.urlpatterns: | ||||
|                         # Check if the plugin has a custom URL pattern | ||||
|                         for url in plugin_urls: | ||||
|                             # Attempt to resolve against the URL pattern as a validation check | ||||
|                             try: | ||||
|                                 url.resolve('') | ||||
|                             except Resolver404: | ||||
|                                 pass | ||||
|  | ||||
|                     urls.append( | ||||
|                         re_path( | ||||
|                             f'^{plugin.slug}/', | ||||
|                             include((plugin_urls, plugin.slug)), | ||||
|                             name=plugin.slug, | ||||
|                         urls.append( | ||||
|                             re_path( | ||||
|                                 f'^{plugin.slug}/', | ||||
|                                 include((plugin_urls, plugin.slug)), | ||||
|                                 name=plugin.slug, | ||||
|                             ) | ||||
|                         ) | ||||
|                     ) | ||||
|             except Exception: | ||||
|                 log_error('get_plugin_urls', plugin=plugin.slug) | ||||
|                 continue | ||||
|                 except Exception: | ||||
|                     log_error('get_plugin_urls', plugin=plugin.slug) | ||||
|                     continue | ||||
|  | ||||
|     # Redirect anything else to the root index | ||||
|     urls.append( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user