2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-30 16:41:35 +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:
Oliver
2025-07-23 18:54:26 +10:00
committed by GitHub
parent 73231ce921
commit 20477fbfcc
5 changed files with 105 additions and 111 deletions

View File

@@ -9,7 +9,7 @@ from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import AppRegistryNotReady
from django.db import transaction from django.db import transaction
from django.db.utils import IntegrityError, OperationalError from django.db.utils import IntegrityError
import structlog import structlog
from allauth.socialaccount.signals import social_account_updated from allauth.socialaccount.signals import social_account_updated
@@ -65,7 +65,8 @@ class InvenTreeConfig(AppConfig):
self.start_background_tasks() self.start_background_tasks()
if not InvenTree.ready.isInTestMode(): # pragma: no cover 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 # Let the background worker check for migrations
InvenTree.tasks.offload_task(InvenTree.tasks.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.offload_task(
InvenTree.tasks.heartbeat, force_async=True, group='heartbeat' InvenTree.tasks.heartbeat, force_async=True, group='heartbeat'
) )
except AppRegistryNotReady: # pragma: no cover
pass
except Exception: except Exception:
pass pass
@@ -194,63 +197,6 @@ class InvenTreeConfig(AppConfig):
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
logger.exception('Error loading tasks for %s: %s', app_name, e) 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): def update_site_url(self):
"""Update the site URL setting. """Update the site URL setting.

View File

@@ -15,12 +15,14 @@ import InvenTree.helpers
logger = structlog.get_logger('inventree') 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).""" """Returns the default currency code (or USD if not specified)."""
from common.settings import get_global_setting from common.settings import get_global_setting
try: 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 except Exception: # pragma: no cover
# Database may not yet be ready, no need to throw an error here # Database may not yet be ready, no need to throw an error here
code = '' code = ''

View File

@@ -32,23 +32,11 @@ class PluginAppConfig(AppConfig):
else: else:
logger.info('Loading InvenTree plugins') logger.info('Loading InvenTree plugins')
if not registry.is_loading: if not registry.is_ready:
# this is the first startup # Mark the registry as ready
try: # This ensures that other apps cannot access the registry before it is fully initialized
from common.models import InvenTreeSetting logger.info('Plugin registry is ready - performing initial load')
registry.set_ready()
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
)
# drop out of maintenance # drop out of maintenance
# makes sure we did not have an error in reloading and maintenance is still active # makes sure we did not have an error in reloading and maintenance is still active

View File

@@ -4,6 +4,7 @@
- Manages setup and teardown of plugin class instances - Manages setup and teardown of plugin class instances
""" """
import functools
import importlib import importlib
import importlib.util import importlib.util
import os import os
@@ -43,6 +44,37 @@ from .plugin import InvenTreePlugin
logger = structlog.get_logger('inventree') 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: class PluginsRegistry:
"""The PluginsRegistry class.""" """The PluginsRegistry class."""
@@ -68,6 +100,8 @@ class PluginsRegistry:
'parameter-exporter', 'parameter-exporter',
] ]
ready: bool
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize registry. """Initialize registry.
@@ -82,6 +116,8 @@ class PluginsRegistry:
str, InvenTreePlugin str, InvenTreePlugin
] = {} # List of all plugin instances ] = {} # 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 # Keep an internal hash of the plugin registry state
self.registry_hash = None self.registry_hash = None
@@ -100,11 +136,35 @@ class PluginsRegistry:
self.installed_apps = [] # Holds all added plugin_paths 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 @property
def is_loading(self) -> bool: def is_loading(self) -> bool:
"""Return True if the plugin registry is currently loading.""" """Return True if the plugin registry is currently loading."""
return self.loading_lock.locked() return self.loading_lock.locked()
@registry_entrypoint()
def get_plugin( def get_plugin(
self, slug: str, active: bool = True, with_mixin: Optional[str] = None self, slug: str, active: bool = True, with_mixin: Optional[str] = None
) -> InvenTreePlugin: ) -> InvenTreePlugin:
@@ -118,9 +178,6 @@ class PluginsRegistry:
Returns: Returns:
InvenTreePlugin or None: The plugin instance if found, otherwise None. 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: if slug not in self.plugins:
logger.warning("Plugin registry has no record of plugin '%s'", slug) logger.warning("Plugin registry has no record of plugin '%s'", slug)
return None return None
@@ -169,6 +226,7 @@ class PluginsRegistry:
return cfg return cfg
@registry_entrypoint()
def set_plugin_state(self, slug: str, state: bool): def set_plugin_state(self, slug: str, state: bool):
"""Set the state(active/inactive) of a plugin. """Set the state(active/inactive) of a plugin.
@@ -176,9 +234,6 @@ class PluginsRegistry:
slug (str): Plugin slug slug (str): Plugin slug
state (bool): Plugin state - true = active, false = inactive 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: if slug not in self.plugins_full:
logger.warning("Plugin registry has no record of plugin '%s'", slug) logger.warning("Plugin registry has no record of plugin '%s'", slug)
return return
@@ -190,6 +245,7 @@ class PluginsRegistry:
# Update the registry hash value # Update the registry hash value
self.update_plugin_hash() self.update_plugin_hash()
@registry_entrypoint()
def call_plugin_function(self, slug: str, func: str, *args, **kwargs): def call_plugin_function(self, slug: str, func: str, *args, **kwargs):
"""Call a member function (named by 'func') of the plugin named by 'slug'. """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. 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) raise_error = kwargs.pop('raise_error', True)
plugin = self.get_plugin(slug) plugin = self.get_plugin(slug)
@@ -220,6 +273,8 @@ class PluginsRegistry:
return plugin_func(*args, **kwargs) return plugin_func(*args, **kwargs)
# region registry functions # region registry functions
@registry_entrypoint(default_value=[])
def with_mixin( def with_mixin(
self, mixin: str, active: bool = True, builtin: Optional[bool] = None self, mixin: str, active: bool = True, builtin: Optional[bool] = None
) -> list[InvenTreePlugin]: ) -> list[InvenTreePlugin]:
@@ -230,9 +285,6 @@ class PluginsRegistry:
active (bool, optional): Filter by 'active' status of plugin. Defaults to True. active (bool, optional): Filter by 'active' status of plugin. Defaults to True.
builtin (bool, optional): Filter by 'builtin' status of plugin. Defaults to None. 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() mixin = str(mixin).lower().strip()
result = [] result = []
@@ -304,6 +356,7 @@ class PluginsRegistry:
logger.info('Finished unloading plugins') logger.info('Finished unloading plugins')
@registry_entrypoint(check_reload=False)
def reload_plugins( def reload_plugins(
self, self,
full_reload: bool = False, full_reload: bool = False,
@@ -369,7 +422,7 @@ class PluginsRegistry:
logger.info('Plugin Registry: Loaded %s plugins', len(self.plugins)) logger.info('Plugin Registry: Loaded %s plugins', len(self.plugins))
except Exception as e: 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') log_error('reload_plugins', scope='plugins')
finally: finally:
@@ -900,6 +953,7 @@ class PluginsRegistry:
return str(data.hexdigest()) return str(data.hexdigest())
@registry_entrypoint(default_value=False, check_reload=False)
def check_reload(self): def check_reload(self):
"""Determine if the registry needs to be reloaded. """Determine if the registry needs to be reloaded.

View File

@@ -18,28 +18,32 @@ def get_plugin_urls():
urls = [] urls = []
if get_global_setting('ENABLE_PLUGINS_URL', False) or settings.PLUGIN_TESTING_SETUP: if registry.is_ready:
for plugin in registry.with_mixin(PluginMixinEnum.URLS): if (
try: get_global_setting('ENABLE_PLUGINS_URL', False)
if plugin_urls := plugin.urlpatterns: or settings.PLUGIN_TESTING_SETUP
# Check if the plugin has a custom URL pattern ):
for url in plugin_urls: for plugin in registry.with_mixin(PluginMixinEnum.URLS):
# Attempt to resolve against the URL pattern as a validation check try:
try: if plugin_urls := plugin.urlpatterns:
url.resolve('') # Check if the plugin has a custom URL pattern
except Resolver404: for url in plugin_urls:
pass # Attempt to resolve against the URL pattern as a validation check
try:
url.resolve('')
except Resolver404:
pass
urls.append( urls.append(
re_path( re_path(
f'^{plugin.slug}/', f'^{plugin.slug}/',
include((plugin_urls, plugin.slug)), include((plugin_urls, plugin.slug)),
name=plugin.slug, name=plugin.slug,
)
) )
) except Exception:
except Exception: log_error('get_plugin_urls', plugin=plugin.slug)
log_error('get_plugin_urls', plugin=plugin.slug) continue
continue
# Redirect anything else to the root index # Redirect anything else to the root index
urls.append( urls.append(