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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Plugin model definitions."""
3import inspect
4import warnings
5from typing import Optional
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 _
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
22class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
23 """A PluginConfig object holds settings for plugins.
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 """
31 @staticmethod
32 def get_api_url():
33 """Return the API URL associated with the PluginConfig model."""
34 return reverse('api-plugin-list')
36 class Meta:
37 """Meta for PluginConfig."""
39 verbose_name = _('Plugin Configuration')
40 verbose_name_plural = _('Plugin Configurations')
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 )
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 )
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 )
68 active = models.BooleanField(
69 default=False, verbose_name=_('Active'), help_text=_('Is the plugin active')
70 )
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
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 {}
91 # functions
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
98 # Append settings from registry
99 plugin = registry.plugins_full.get(self.key, None)
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
107 # Ignore if the plugin is not active
108 if not self.active:
109 return None
111 result = getattr(plugin, name, None)
113 if result is not None:
114 result = str(result)
116 return result
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 }
134 # Save plugin
135 self.plugin: InvenTreePlugin = plugin
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
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
149 mandatory = self.is_mandatory()
151 if mandatory:
152 # Force active if mandatory plugin
153 self.active = True
155 super().save(force_insert, force_update, *args, **kwargs)
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)
165 self.__org_active = self.active
167 @admin.display(boolean=True, description=_('Installed'))
168 def is_installed(self) -> bool:
169 """Simple check to determine if this plugin is installed.
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
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
182 return self.plugin.check_is_sample()
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
190 return self.plugin.check_is_builtin()
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
200 # Hard-coded list of mandatory "builtin" plugins
201 return self.key in registry.MANDATORY_PLUGINS
203 def is_active(self) -> bool:
204 """Return True if this plugin is active.
206 Note that 'mandatory' plugins are always considered 'active',
207 """
208 return self.is_mandatory() or self.active
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
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
219 pkg_name = getattr(self.plugin, 'package_name', None)
220 return pkg_name is not None
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.
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
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
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
240 return None
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
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
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
257 return {}
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
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
266 self.active = active
267 self.save()
269 trigger_event(PluginEvents.PLUGIN_ACTIVATED, slug=self.key, active=active)
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 )
282class PluginSetting(common.models.BaseInvenTreeSetting):
283 """This model represents settings for individual plugins."""
285 typ = 'plugin'
286 extra_unique_fields = ['plugin']
288 class Meta:
289 """Meta for PluginSetting."""
291 unique_together = [('plugin', 'key')]
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)
297 plugin = models.ForeignKey(
298 PluginConfig,
299 related_name='settings',
300 null=False,
301 verbose_name=_('Plugin'),
302 on_delete=models.CASCADE,
303 )
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
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])
314 super().save(*args, **kwargs)
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
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]
327 return super().get_setting_default(key, **kwargs)
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
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]
340 return super().get_setting(key, backup_value=backup_value, **kwargs)
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.
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).
349 Settings can be provided by the caller, as kwargs['settings'].
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)
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, {})
362 return super().get_setting_definition(key, **kwargs)
365class PluginUserSetting(common.models.BaseInvenTreeSetting):
366 """This model represents user-specific settings for individual plugins.
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 """
372 typ = 'plugin_user'
373 extra_unique_fields = ['plugin', 'user']
375 class Meta:
376 """Meta for PluginUserSetting."""
378 unique_together = [('plugin', 'user', 'key')]
380 plugin = models.ForeignKey(
381 PluginConfig,
382 related_name='user_settings',
383 null=False,
384 verbose_name=_('Plugin'),
385 on_delete=models.CASCADE,
386 )
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 )
397 def __str__(self) -> str:
398 """Nice name of printing."""
399 return f'{self.key} (for {self.user}): {self.value}'
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.
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).
408 Settings can be provided by the caller, as kwargs['settings'].
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)
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, {})
421 return super().get_setting_definition(key, **kwargs)