Coverage for src/backend/InvenTree/plugin/models.py: 72%

192 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 17:47 +0000

1"""Plugin model definitions.""" 

2 

3import inspect 

4import warnings 

5from typing import Optional 

6 

7from django.conf import settings 

8from django.contrib import admin 

9from django.contrib.auth.models import User 

10from django.db import models 

11from django.urls import reverse 

12from django.utils.translation import gettext_lazy as _ 

13 

14import common.models 

15import InvenTree.models 

16import plugin.staticfiles 

17from plugin import InvenTreePlugin 

18from plugin.events import PluginEvents, trigger_event 

19from plugin.registry import registry 

20 

21 

22class PluginConfig(InvenTree.models.MetadataMixin, models.Model): 

23 """A PluginConfig object holds settings for plugins. 

24 

25 Attributes: 

26 key: slug of the plugin (this must be unique across all installed plugins!) 

27 name: Name of the plugin - serves for a manual double check if the right plugin is used 

28 active: Should the plugin be loaded? 

29 """ 

30 

31 @staticmethod 

32 def get_api_url(): 

33 """Return the API URL associated with the PluginConfig model.""" 

34 return reverse('api-plugin-list') 

35 

36 class Meta: 

37 """Meta for PluginConfig.""" 

38 

39 verbose_name = _('Plugin Configuration') 

40 verbose_name_plural = _('Plugin Configurations') 

41 

42 key = models.CharField( 

43 unique=True, 

44 db_index=True, 

45 max_length=255, 

46 verbose_name=_('Key'), 

47 help_text=_('Key of plugin'), 

48 ) 

49 

50 name = models.CharField( 

51 null=True, 

52 blank=True, 

53 max_length=255, 

54 verbose_name=_('Name'), 

55 help_text=_('Name of the plugin'), 

56 ) 

57 

58 package_name = models.CharField( 

59 null=True, 

60 blank=True, 

61 max_length=255, 

62 verbose_name=_('Package Name'), 

63 help_text=_( 

64 'Name of the installed package, if the plugin was installed via PIP' 

65 ), 

66 ) 

67 

68 active = models.BooleanField( 

69 default=False, verbose_name=_('Active'), help_text=_('Is the plugin active') 

70 ) 

71 

72 def __str__(self) -> str: 

73 """Nice name for printing.""" 

74 name = f'{self.name} - {self.key}' 

75 if not self.active: 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 name += ' (not active)' 

77 return name 

78 

79 # extra attributes from the registry 

80 def mixins(self): 

81 """Returns all registered mixins.""" 

82 try: 

83 if inspect.isclass(self.plugin): 

84 return self.plugin.get_registered_mixins( 

85 self, with_base=True, with_cls=False 

86 ) 

87 return self.plugin.get_registered_mixins(with_base=True, with_cls=False) 

88 except (AttributeError, ValueError): # pragma: no cover 

89 return {} 

90 

91 # functions 

92 

93 def __init__(self, *args, **kwargs): 

94 """Override to set original state of the plugin-config instance.""" 

95 super().__init__(*args, **kwargs) 

96 self.__org_active = self.active 

97 

98 # Append settings from registry 

99 plugin = registry.plugins_full.get(self.key, None) 

100 

101 def get_plugin_meta(name): 

102 """Return a meta-value associated with this plugin.""" 

103 # Ignore if the plugin config is not defined 

104 if not plugin: 

105 return None 

106 

107 # Ignore if the plugin is not active 

108 if not self.active: 

109 return None 

110 

111 result = getattr(plugin, name, None) 

112 

113 if result is not None: 

114 result = str(result) 

115 

116 return result 

117 

118 self.meta = { 

119 key: get_plugin_meta(key) 

120 for key in [ 

121 'slug', 

122 'human_name', 

123 'description', 

124 'author', 

125 'pub_date', 

126 'version', 

127 'website', 

128 'license', 

129 'package_path', 

130 'settings_url', 

131 ] 

132 } 

133 

134 # Save plugin 

135 self.plugin: InvenTreePlugin = plugin 

136 

137 def __getstate__(self): 

138 """Customize pickling behavior.""" 

139 state = super().__getstate__() 

140 state.pop( 

141 'plugin', None 

142 ) # plugin cannot be pickled in some circumstances when used with drf views, remove it (#5408) 

143 return state 

144 

145 def save(self, force_insert=False, force_update=False, *args, **kwargs): 

146 """Extend save method to reload plugins if the 'active' status changes.""" 

147 no_reload = kwargs.pop('no_reload', False) # check if no_reload flag is set 

148 

149 mandatory = self.is_mandatory() 

150 

151 if mandatory: 

152 # Force active if mandatory plugin 

153 self.active = True 

154 

155 super().save(force_insert, force_update, *args, **kwargs) 

156 

157 if not no_reload and self.active != self.__org_active and not mandatory: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 if settings.PLUGIN_TESTING: 

159 warnings.warn( 

160 f'A plugin registry reload was triggered for plugin {self.key}', 

161 stacklevel=2, 

162 ) 

163 registry.reload_plugins(full_reload=True, force_reload=True, collect=True) 

164 

165 self.__org_active = self.active 

166 

167 @admin.display(boolean=True, description=_('Installed')) 

168 def is_installed(self) -> bool: 

169 """Simple check to determine if this plugin is installed. 

170 

171 A plugin might not be installed if it has been removed from the system, 

172 but the PluginConfig associated with it still exists. 

173 """ 

174 return self.plugin is not None 

175 

176 @admin.display(boolean=True, description=_('Sample plugin')) 

177 def is_sample(self) -> bool: 

178 """Is this plugin a sample app?""" 

179 if not self.plugin: 

180 return False 

181 

182 return self.plugin.check_is_sample() 

183 

184 @admin.display(boolean=True, description=_('Builtin Plugin')) 

185 def is_builtin(self) -> bool: 

186 """Return True if this is a 'builtin' plugin.""" 

187 if not self.plugin: 

188 return False 

189 

190 return self.plugin.check_is_builtin() 

191 

192 @admin.display(boolean=True, description=_('Mandatory Plugin')) 

193 def is_mandatory(self) -> bool: 

194 """Return True if this plugin is mandatory.""" 

195 # List of run-time configured mandatory plugins 

196 if settings.PLUGINS_MANDATORY: 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true

197 if self.key in settings.PLUGINS_MANDATORY: 

198 return True 

199 

200 # Hard-coded list of mandatory "builtin" plugins 

201 return self.key in registry.MANDATORY_PLUGINS 

202 

203 def is_active(self) -> bool: 

204 """Return True if this plugin is active. 

205 

206 Note that 'mandatory' plugins are always considered 'active', 

207 """ 

208 return self.is_mandatory() or self.active 

209 

210 @admin.display(boolean=True, description=_('Package Plugin')) 

211 def is_package(self) -> bool: 

212 """Return True if this is a 'package' plugin.""" 

213 if self.package_name: 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true

214 return True 

215 

216 if not self.plugin: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true

217 return False 

218 

219 pkg_name = getattr(self.plugin, 'package_name', None) 

220 return pkg_name is not None 

221 

222 @property 

223 def admin_source(self) -> Optional[str]: 

224 """Return the path to the javascript file which renders custom admin content for this plugin. 

225 

226 - It is required that the file provides a 'renderPluginSettings' function! 

227 """ 

228 if not self.plugin: 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true

229 return None 

230 

231 if not self.is_installed() or not self.active: 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 return None 

233 

234 if hasattr(self.plugin, 'get_admin_source'): 234 ↛ 240line 234 didn't jump to line 240 because the condition on line 234 was always true

235 try: 

236 return self.plugin.get_admin_source() 

237 except Exception: 

238 pass 

239 

240 return None 

241 

242 @property 

243 def admin_context(self) -> Optional[dict]: 

244 """Return the context data for the admin integration.""" 

245 if not self.plugin: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true

246 return None 

247 

248 if not self.is_installed() or not self.active: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 return None 

250 

251 if hasattr(self.plugin, 'get_admin_context'): 251 ↛ 257line 251 didn't jump to line 257 because the condition on line 251 was always true

252 try: 

253 return self.plugin.get_admin_context() 

254 except Exception: 

255 pass 

256 

257 return {} 

258 

259 def activate(self, active: bool) -> None: 

260 """Set the 'active' status of this plugin instance.""" 

261 from InvenTree.tasks import check_for_migrations, offload_task 

262 

263 if self.active == active: 263 ↛ 266line 263 didn't jump to line 266 because the condition on line 263 was always true

264 return 

265 

266 self.active = active 

267 self.save() 

268 

269 trigger_event(PluginEvents.PLUGIN_ACTIVATED, slug=self.key, active=active) 

270 

271 if active: 

272 offload_task(check_for_migrations) 

273 offload_task( 

274 plugin.staticfiles.copy_plugin_static_files, self.key, group='plugin' 

275 ) 

276 else: 

277 offload_task( 

278 plugin.staticfiles.clear_plugin_static_files, self.key, group='plugin' 

279 ) 

280 

281 

282class PluginSetting(common.models.BaseInvenTreeSetting): 

283 """This model represents settings for individual plugins.""" 

284 

285 typ = 'plugin' 

286 extra_unique_fields = ['plugin'] 

287 

288 class Meta: 

289 """Meta for PluginSetting.""" 

290 

291 unique_together = [('plugin', 'key')] 

292 

293 def to_native_value(self): 

294 """Return the 'native' value of this setting.""" 

295 return self.__class__.get_setting(self.key, plugin=self.plugin) 

296 

297 plugin = models.ForeignKey( 

298 PluginConfig, 

299 related_name='settings', 

300 null=False, 

301 verbose_name=_('Plugin'), 

302 on_delete=models.CASCADE, 

303 ) 

304 

305 def save(self, *args, **kwargs): 

306 """When saving a plugin setting, enforce any config-level overrides.""" 

307 from common.settings import plugin_setting_overrides 

308 

309 if self.plugin_id and self.plugin: 309 ↛ 314line 309 didn't jump to line 314 because the condition on line 309 was always true

310 overrides = plugin_setting_overrides(self.plugin.key) 

311 if self.key in overrides: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 self.value = str(overrides[self.key]) 

313 

314 super().save(*args, **kwargs) 

315 

316 @classmethod 

317 def get_setting_default(cls, key, **kwargs): 

318 """Return the default value for a plugin setting, respecting config overrides.""" 

319 from common.settings import plugin_setting_overrides 

320 

321 plugin = kwargs.get('plugin') 

322 if plugin: 322 ↛ 327line 322 didn't jump to line 327 because the condition on line 322 was always true

323 overrides = plugin_setting_overrides(plugin.key) 

324 if key in overrides: 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true

325 return overrides[key] 

326 

327 return super().get_setting_default(key, **kwargs) 

328 

329 @classmethod 

330 def get_setting(cls, key, backup_value=None, **kwargs): 

331 """Get the value of a plugin setting, respecting config overrides.""" 

332 from common.settings import plugin_setting_overrides 

333 

334 plugin = kwargs.get('plugin') 

335 if plugin: 335 ↛ 340line 335 didn't jump to line 340 because the condition on line 335 was always true

336 overrides = plugin_setting_overrides(plugin.key) 

337 if key in overrides: 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 return overrides[key] 

339 

340 return super().get_setting(key, backup_value=backup_value, **kwargs) 

341 

342 @classmethod 

343 def get_setting_definition(cls, key, **kwargs): 

344 """In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS', which is a dict object that fully defines all the setting parameters. 

345 

346 Here, unlike the BaseInvenTreeSetting, we do not know the definitions of all settings 

347 'ahead of time' (as they are defined externally in the plugins). 

348 

349 Settings can be provided by the caller, as kwargs['settings']. 

350 

351 If not provided, we'll look at the plugin registry to see what settings are available, 

352 (if the plugin is specified!) 

353 """ 

354 if 'settings' not in kwargs: 354 ↛ 362line 354 didn't jump to line 362 because the condition on line 354 was always true

355 plugin = kwargs.pop('plugin', None) 

356 

357 if plugin: 357 ↛ 362line 357 didn't jump to line 362 because the condition on line 357 was always true

358 mixin_settings = getattr(registry, 'mixins_settings', None) 

359 if mixin_settings: 359 ↛ 362line 359 didn't jump to line 362 because the condition on line 359 was always true

360 kwargs['settings'] = mixin_settings.get(plugin.key, {}) 

361 

362 return super().get_setting_definition(key, **kwargs) 

363 

364 

365class PluginUserSetting(common.models.BaseInvenTreeSetting): 

366 """This model represents user-specific settings for individual plugins. 

367 

368 In contrast with the PluginSetting model, which holds global settings for plugins, 

369 this model allows for user-specific settings that can be defined by each user. 

370 """ 

371 

372 typ = 'plugin_user' 

373 extra_unique_fields = ['plugin', 'user'] 

374 

375 class Meta: 

376 """Meta for PluginUserSetting.""" 

377 

378 unique_together = [('plugin', 'user', 'key')] 

379 

380 plugin = models.ForeignKey( 

381 PluginConfig, 

382 related_name='user_settings', 

383 null=False, 

384 verbose_name=_('Plugin'), 

385 on_delete=models.CASCADE, 

386 ) 

387 

388 user = models.ForeignKey( 

389 User, 

390 on_delete=models.CASCADE, 

391 null=False, 

392 verbose_name=_('User'), 

393 help_text=_('User'), 

394 related_name='plugin_settings', 

395 ) 

396 

397 def __str__(self) -> str: 

398 """Nice name of printing.""" 

399 return f'{self.key} (for {self.user}): {self.value}' 

400 

401 @classmethod 

402 def get_setting_definition(cls, key, **kwargs): 

403 """In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS', which is a dict object that fully defines all the setting parameters. 

404 

405 Here, unlike the BaseInvenTreeSetting, we do not know the definitions of all settings 

406 'ahead of time' (as they are defined externally in the plugins). 

407 

408 Settings can be provided by the caller, as kwargs['settings']. 

409 

410 If not provided, we'll look at the plugin registry to see what settings are available, 

411 (if the plugin is specified!) 

412 """ 

413 if 'settings' not in kwargs: 

414 plugin = kwargs.pop('plugin', None) 

415 

416 if plugin: 

417 mixin_user_settings = getattr(registry, 'mixins_user_settings', None) 

418 if mixin_user_settings: 

419 kwargs['settings'] = mixin_user_settings.get(plugin.key, {}) 

420 

421 return super().get_setting_definition(key, **kwargs)