Coverage for src/backend/InvenTree/plugin/base/event/events.py: 56%
84 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"""Functions for triggering and responding to server side events."""
3from django.conf import settings
4from django.db import transaction
5from django.db.models.signals import post_delete, post_save
6from django.dispatch.dispatcher import receiver
8import structlog
9from opentelemetry import trace
11import InvenTree.exceptions
12from common.settings import get_global_setting
13from InvenTree.ready import canAppAccessDatabase, isImportingData
14from InvenTree.tasks import offload_task
15from plugin import PluginMixinEnum
16from plugin.registry import registry
18tracer = trace.get_tracer(__name__)
19logger = structlog.get_logger('inventree')
22@tracer.start_as_current_span('trigger_event')
23def trigger_event(event: str, *args, **kwargs) -> None:
24 """Trigger an event with optional arguments.
26 Arguments:
27 event: The event to trigger
28 *args: Additional arguments to pass to the event handler
29 **kwargs: Additional keyword arguments to pass to the event handler
31 This event will be stored in the database, and the worker will respond to it later on.
32 """
33 if not get_global_setting('ENABLE_PLUGINS_EVENTS', False): 33 ↛ 38line 33 didn't jump to line 38 because the condition on line 33 was always true
34 # Do nothing if plugin events are not enabled
35 return
37 # Ensure event name is stringified
38 event = str(event).strip()
40 # Make sure the database can be accessed and is not being tested rn
41 if (
42 not canAppAccessDatabase(allow_shell=True)
43 and not settings.PLUGIN_TESTING_EVENTS
44 ):
45 logger.debug("Ignoring triggered event '%s' - database not ready", event)
46 return
48 logger.debug("Event triggered: '%s'", event)
50 force_async = kwargs.pop('force_async', True)
52 # If we are running in testing mode, we can enable or disable async processing
53 if settings.PLUGIN_TESTING_EVENTS:
54 force_async = settings.PLUGIN_TESTING_EVENTS_ASYNC
56 kwargs['force_async'] = force_async
58 offload_task(register_event, event, *args, group='plugin', **kwargs)
61@tracer.start_as_current_span('register_event')
62def register_event(event, *args, **kwargs):
63 """Register the event with any interested plugins.
65 Note: This function is processed by the background worker,
66 as it performs multiple database access operations.
67 """
68 logger.debug("Registering triggered event: '%s'", event)
70 # Determine if there are any plugins which are interested in responding
71 if settings.PLUGIN_TESTING or get_global_setting('ENABLE_PLUGINS_EVENTS'):
72 # Check if the plugin registry needs to be reloaded
73 registry.check_reload()
75 with transaction.atomic():
76 for plugin in registry.with_mixin(PluginMixinEnum.EVENTS, active=True):
77 # Let the plugin decide if it wants to process this event
78 if not plugin.wants_process_event(event):
79 continue
81 logger.debug("Registering callback for plugin '%s'", plugin.slug)
83 # This task *must* be processed by the background worker,
84 # unless we are running CI tests
85 if 'force_async' not in kwargs and not settings.PLUGIN_TESTING_EVENTS:
86 kwargs['force_async'] = True
88 # Offload a separate task for each plugin
89 offload_task(
90 process_event, plugin.slug, event, *args, group='plugin', **kwargs
91 )
94@tracer.start_as_current_span('process_event')
95def process_event(plugin_slug, event, *args, **kwargs):
96 """Respond to a triggered event.
98 This function is run by the background worker process.
99 This function may queue multiple functions to be handled by the background worker.
100 """
101 plugin = registry.get_plugin(plugin_slug, active=True)
103 if plugin is None: # pragma: no cover
104 logger.error("Could not find matching active plugin for '%s'", plugin_slug)
105 return
107 logger.debug("Plugin '%s' is processing triggered event '%s'", plugin_slug, event)
109 try:
110 plugin.process_event(event, *args, **kwargs)
111 except Exception as e:
112 # Log the exception to the database
113 InvenTree.exceptions.log_error('process_event', plugin=plugin_slug)
114 # Re-throw the exception so that the background worker tries again
115 raise e
118def allow_table_event(table_name):
119 """Determine if an automatic event should be fired for a given table.
121 We *do not* want events to be fired for some tables!
122 """
123 # Prevent table events during the data import process
124 if isImportingData(): 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
125 return False # pragma: no cover
127 # Prevent table events when in testing mode (saves a lot of time)
128 if settings.TESTING and not settings.TESTING_TABLE_EVENTS: 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 return False
131 table_name = table_name.lower().strip()
133 # Ignore any tables which start with these prefixes
134 ignore_prefixes = [
135 'account_',
136 'auth_',
137 'authtoken_',
138 'django_',
139 'error_',
140 'exchange_',
141 'otp_',
142 'plugin_',
143 'socialaccount_',
144 'user_',
145 'users_',
146 'importer_',
147 ]
149 if any(table_name.startswith(prefix) for prefix in ignore_prefixes):
150 return False
152 ignore_tables = [
153 'common_notificationentry',
154 'common_notificationmessage',
155 'common_webhookendpoint',
156 'common_webhookmessage',
157 'part_partpricing',
158 'part_partstocktake',
159 ]
161 return table_name not in ignore_tables
164@receiver(post_save)
165def after_save(sender, instance, created, **kwargs):
166 """Trigger an event whenever a database entry is saved."""
167 table = sender.objects.model._meta.db_table
169 instance_id = getattr(instance, 'id', None)
171 if instance_id is None:
172 return
174 if not allow_table_event(table):
175 return
177 if created:
178 trigger_event(f'{table}.created', id=instance.id, model=sender.__name__)
179 else:
180 trigger_event(f'{table}.saved', id=instance.id, model=sender.__name__)
183@receiver(post_delete)
184def after_delete(sender, instance, **kwargs):
185 """Trigger an event whenever a database entry is deleted."""
186 table = sender.objects.model._meta.db_table
188 if not allow_table_event(table):
189 return
191 instance_id = None
193 if instance: 193 ↛ 196line 193 didn't jump to line 196 because the condition on line 193 was always true
194 instance_id = getattr(instance, 'id', None)
196 trigger_event(f'{table}.deleted', model=sender.__name__, id=instance_id)