mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-30 00:21:34 +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