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

1"""Registry for loading and managing multiple plugins at run-time. 

2 

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

6 

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 

18 

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 _ 

26 

27import structlog 

28 

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 

34 

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 

44 

45logger = structlog.get_logger('inventree') 

46 

47 

48def registry_entrypoint(check_reload: bool = True, default_value: Any = None) -> Any: 

49 """Function decorator for registry entrypoints methods. 

50 

51 - Ensure that the registry is ready before calling the method. 

52 - Check if the registry needs to be reloaded. 

53 """ 

54 

55 def decorator(method): 

56 """Decorator to ensure registry is ready before calling the method.""" 

57 

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 

67 

68 # Check if the registry needs to be reloaded 

69 if check_reload: 

70 self.check_reload() 

71 

72 return method(self, *args, **kwargs) 

73 

74 return wrapper 

75 

76 return decorator 

77 

78 

79class PluginsRegistry: 

80 """The PluginsRegistry class.""" 

81 

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 

86 

87 DEFAULT_MIXIN_ORDER = [SettingsMixin, ScheduleMixin, AppMixin, UrlsMixin] 

88 

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 ] 

104 

105 ready: bool 

106 

107 def __init__(self) -> None: 

108 """Initialize registry. 

109 

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 

120 

121 self.ready = False # Marks if the registry is ready to be used 

122 

123 # Keep an internal hash of the plugin registry state 

124 self.registry_hash: Optional[str] = None 

125 

126 self.plugin_modules: list[InvenTreePlugin] = [] # Holds all discovered plugins 

127 self.mixin_modules: dict[str, Any] = {} # Holds all discovered mixins 

128 

129 self.errors: dict[str, list[Any]] = {} # Holds errors discovered during loading 

130 

131 self.loading_lock = Lock() # Lock to prevent multiple loading at the same time 

132 

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 

138 

139 self.installed_apps = [] # Holds all added plugin_paths 

140 

141 def set_ready(self): 

142 """Set the registry as ready to be used. 

143 

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 

148 

149 logger.info('Initializing plugin registry') 

150 

151 self.ready = True 

152 

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

167 

168 # Perform initial plugin discovery 

169 self.reload_plugins(full_reload=True, force_reload=True, collect=True) 

170 

171 @property 

172 def is_ready(self) -> bool: 

173 """Return True if the plugin registry is ready to be used.""" 

174 return self.ready 

175 

176 @property 

177 def is_loading(self) -> bool: 

178 """Return True if the plugin registry is currently loading.""" 

179 return self.loading_lock.locked() 

180 

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

186 

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. 

191 

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 

198 

199 plg = self.plugins[slug] 

200 

201 config = self.get_plugin_config(slug) 

202 

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 

206 

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 

209 

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 

212 

213 return plg 

214 

215 def get_plugin_config(self, slug: str, name: str | None = None): 

216 """Return the matching PluginConfig instance for a given plugin. 

217 

218 Arguments: 

219 slug: The plugin slug 

220 name: The plugin name (optional) 

221 """ 

222 import InvenTree.ready 

223 from plugin.models import PluginConfig 

224 

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 ) 

232 

233 try: 

234 cfg = PluginConfig.objects.filter(key=slug).first() 

235 

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) 

241 

242 except PluginConfig.DoesNotExist: # pragma: no cover 

243 return None 

244 except (IntegrityError, OperationalError, ProgrammingError): # pragma: no cover 

245 return None 

246 

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

254 

255 return cfg 

256 

257 @registry_entrypoint() 

258 def set_plugin_state(self, slug: str, state: bool): 

259 """Set the state(active/inactive) of a plugin. 

260 

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 

268 

269 cfg = self.get_plugin_config(slug) 

270 cfg.active = state 

271 cfg.save() 

272 

273 # Update the registry hash value 

274 self.update_plugin_hash() 

275 

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'. 

279 

280 As this is intended to be run by the background worker, 

281 we do not perform any try/except here. 

282 

283 Instead, any error messages are returned to the worker. 

284 """ 

285 raise_error = kwargs.pop('raise_error', True) 

286 

287 plugin = self.get_plugin(slug) 

288 

289 if not plugin: 

290 if raise_error: 

291 raise AttributeError(f"Plugin '{slug}' not found") 

292 return 

293 

294 plugin_func = getattr(plugin, func) 

295 

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 

300 

301 return plugin_func(*args, **kwargs) 

302 

303 # region registry functions 

304 

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. 

310 

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}' 

320 

321 configs = InvenTree.cache.get_session_cache(cache_key) 

322 

323 if not configs: 

324 try: 

325 # Pre-fetch the PluginConfig objects to avoid multiple database queries 

326 from plugin.models import PluginConfig 

327 

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 [] 

335 

336 mixin = str(mixin).lower().strip() 

337 

338 plugins = [] 

339 

340 for plugin in self.plugins.values(): 

341 try: 

342 if not plugin.mixin_enabled(mixin): 

343 continue 

344 except MixinNotImplementedError: 

345 continue 

346 

347 config = configs.get(plugin.slug) or plugin.plugin_config() 

348 

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 

352 

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 

355 

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 

358 

359 plugins.append(plugin) 

360 

361 return plugins 

362 

363 # endregion 

364 

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. 

370 

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

376 

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 

386 

387 # ensure plugins_loaded is True 

388 self.plugins_loaded = True 

389 

390 logger.debug('Finished loading plugins') 

391 

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 

395 

396 trigger_event(PluginEvents.PLUGINS_LOADED) 

397 

398 def _unload_plugins(self, force_reload: bool = False): 

399 """Unload and deactivate all IntegrationPlugins. 

400 

401 Args: 

402 force_reload (bool, optional): Also reload base apps. Defaults to False. 

403 """ 

404 logger.info('Start unloading plugins') 

405 

406 # remove all plugins from registry 

407 self._clean_registry() 

408 

409 # deactivate all integrations 

410 self._deactivate_plugins(force_reload=force_reload) 

411 

412 logger.info('Finished unloading plugins') 

413 

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. 

424 

425 This should be considered the single point of entry for loading plugins! 

426 

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 

438 

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 

442 

443 # Reset the loading error state 

444 if clear_errors: 

445 self.errors = {} 

446 

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 

453 

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 ) 

461 

462 # If we are in a container environment, reload the entire plugins file 

463 if collect: 

464 logger.info('Collecting plugins') 

465 

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

468 

469 self.plugin_modules = self.collect_plugins() 

470 

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) 

475 

476 self.update_plugin_hash() 

477 logger.info('Plugin Registry: Loaded %s plugins', len(self.plugins)) 

478 

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 

485 

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 ) 

494 

495 except Exception as e: 

496 logger.exception('Unexpected error during plugin reload: %s', e) 

497 log_error('reload_plugins', scope='plugins') 

498 

499 finally: 

500 # Ensure the lock is released always 

501 self.loading_lock.release() 

502 

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'] 

507 

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 

510 

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

514 

515 if settings.TESTING: 

516 dirs.append('plugin.testing') 

517 

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

522 

523 # Load from user specified directories (unless in testing mode) 

524 dirs.append('plugins') 

525 

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

530 

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 

541 

542 # Ensure the directory has an __init__.py file 

543 init_filename = pd.joinpath('__init__.py') 

544 

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 

554 

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) 

562 

563 # Add path 

564 dirs.append(pd_path) 

565 logger.info("Added plugin directory: '%s' as '%s'", pd, pd_path) 

566 

567 return dirs 

568 

569 def collect_plugins(self): 

570 """Collect plugins from all possible ways of loading. Returned as list.""" 

571 collected_plugins = [] 

572 

573 # Collect plugins from paths 

574 for plugin_dir in self.plugin_dirs(): 

575 logger.debug("Loading plugins from directory '%s'", plugin_dir) 

576 

577 parent_path = None 

578 parent_obj = Path(plugin_dir) 

579 

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 

585 

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) 

599 

600 modules = get_plugins(raw_module, InvenTreePlugin, path=parent_path) 

601 

602 for item in modules or []: 

603 collected_plugins.append(item) 

604 

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 ) 

616 

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

625 

626 # Log collected plugins 

627 logger.info('Collected %s plugins', len(collected_plugins)) 

628 logger.debug(', '.join([a.__module__ for a in collected_plugins])) 

629 

630 return collected_plugins 

631 

632 def discover_mixins(self): 

633 """Discover all mixins from plugins and register them.""" 

634 collected_mixins = {} 

635 

636 for plg in self.plugins.values(): 

637 collected_mixins.update(plg.get_registered_mixins()) 

638 

639 self.mixin_modules = collected_mixins 

640 

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 

644 

645 file_hash = plugins_file_hash() 

646 

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 

650 

651 # endregion 

652 

653 # region general internal loading / activating / deactivating / unloading 

654 def _init_plugin(self, plugin, configs: dict): 

655 """Initialise a single plugin. 

656 

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 

663 

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 

675 

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! 

681 

682 logger.info("Loading plugin '%s'", plg_key) 

683 

684 if plg_key in configs: 

685 plg_db = configs[plg_key] 

686 

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) 

693 

694 plugin.db = plg_db 

695 

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

699 

700 package_name = None 

701 

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) 

705 

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

711 

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

716 

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 ) 

721 

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) 

729 

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__ 

735 

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) 

753 

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) 

759 

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

767 

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 

774 

775 logger.warning('Plugin `%s` could not be loaded', plg_name) 

776 

777 # Safe extra attributes 

778 plg_i.is_package = getattr(plg_i, 'is_package', False) 

779 

780 plg_i.pk = plg_db.pk if plg_db else None 

781 plg_i.db = plg_db 

782 

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) 

787 

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) 

803 

804 def _init_plugins(self): 

805 """Initialise all found plugins. 

806 

807 Args: 

808 disabled (str, optional): Loading path of disabled app. Defaults to None. 

809 

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 

815 

816 logger.debug('Starting plugin initialization') 

817 

818 # Fetch and cache list of existing plugin configuration instances 

819 plugin_configs = {cfg.key: cfg for cfg in PluginConfig.objects.all()} 

820 

821 # Initialize plugins 

822 for plg in self.plugin_modules: 

823 # Attempt to load each individual plugin 

824 attempts = settings.PLUGIN_RETRY 

825 

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 

828 

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) 

836 

837 logger.exception( 

838 '[PLUGIN] Encountered an error with %s:\n%s', 

839 getattr(error, 'path', None), 

840 error, 

841 ) 

842 

843 logger.debug('Finished plugin initialization') 

844 

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 

849 

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 ] 

856 

857 # Final list of mixins 

858 return order 

859 

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. 

867 

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

874 

875 # Activate integrations 

876 plugins = self.plugins.items() 

877 logger.info('Found %s active plugins', len(plugins)) 

878 

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 ) 

888 

889 logger.debug('Done activating') 

890 

891 def _deactivate_plugins(self, force_reload: bool = False): 

892 """Run deactivation functions for all plugins. 

893 

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) 

900 

901 logger.debug('Finished deactivating plugins') 

902 

903 # endregion 

904 

905 # region mixin specific loading ... 

906 def _try_reload(self, cmd, *args, **kwargs): 

907 """Wrapper to try reloading the apps. 

908 

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) 

916 

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. 

924 

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 

933 

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) 

942 

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) 

947 

948 self.installed_apps = [] 

949 

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] = {} 

955 

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. 

958 

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. 

961 

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 

967 

968 for index, url in enumerate(urlpatterns): 

969 app_name = getattr(url, 'app_name', None) 

970 

971 admin_url = settings.INVENTREE_ADMIN_URL 

972 

973 if app_name == 'admin': 

974 urlpatterns[index] = path( 

975 f'{admin_url}/', admin.site.urls, name='inventree-admin' 

976 ) 

977 

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

980 

981 # Refresh the URL cache 

982 clear_url_caches() 

983 

984 # endregion 

985 

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

990 

991 try: 

992 old_hash = get_global_setting( 

993 '_PLUGIN_REGISTRY_HASH', '', create=False, cache=False 

994 ) 

995 except Exception: 

996 old_hash = '' 

997 

998 if old_hash != self.registry_hash: 

999 try: 

1000 logger.info('Updating plugin registry hash: %s', self.registry_hash) 

1001 

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) 

1011 

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 ] 

1023 

1024 def calculate_plugin_hash(self): 

1025 """Calculate a 'hash' value for the current registry. 

1026 

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 

1031 

1032 data = md5() 

1033 

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

1044 

1045 for k in self.plugin_settings_keys(): 

1046 try: 

1047 val = get_global_setting(k, create=False) 

1048 msg = f'{k}-{val}' 

1049 

1050 data.update(msg.encode()) 

1051 except Exception: 

1052 pass 

1053 

1054 return str(data.hexdigest()) 

1055 

1056 @registry_entrypoint(default_value=False, check_reload=False) 

1057 def check_reload(self): 

1058 """Determine if the registry needs to be reloaded. 

1059 

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 

1065 

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 

1071 

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 

1075 

1076 InvenTree.cache.set_session_cache('plugin_registry_checked', True) 

1077 

1078 logger.debug('Checking plugin registry hash') 

1079 

1080 # If not already cached, calculate the hash 

1081 if not self.registry_hash: 

1082 self.registry_hash = self.calculate_plugin_hash() 

1083 

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 

1089 

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 

1095 

1096 # endregion 

1097 

1098 

1099registry: PluginsRegistry = PluginsRegistry() 

1100 

1101 

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) 

1105 

1106 

1107def _load_source(modname, filename): 

1108 """Helper function to replace deprecated & removed imp.load_source. 

1109 

1110 See https://docs.python.org/3/whatsnew/3.12.html#imp 

1111 """ 

1112 if modname in sys.modules: 

1113 del sys.modules[modname] 

1114 

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) 

1120 

1121 sys.modules[module.__name__] = module 

1122 

1123 loader = spec.loader 

1124 if loader is not None: 

1125 loader.exec_module(module) 

1126 

1127 return module