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

1"""Functions for triggering and responding to server side events.""" 

2 

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 

7 

8import structlog 

9from opentelemetry import trace 

10 

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 

17 

18tracer = trace.get_tracer(__name__) 

19logger = structlog.get_logger('inventree') 

20 

21 

22@tracer.start_as_current_span('trigger_event') 

23def trigger_event(event: str, *args, **kwargs) -> None: 

24 """Trigger an event with optional arguments. 

25 

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 

30 

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 

36 

37 # Ensure event name is stringified 

38 event = str(event).strip() 

39 

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 

47 

48 logger.debug("Event triggered: '%s'", event) 

49 

50 force_async = kwargs.pop('force_async', True) 

51 

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 

55 

56 kwargs['force_async'] = force_async 

57 

58 offload_task(register_event, event, *args, group='plugin', **kwargs) 

59 

60 

61@tracer.start_as_current_span('register_event') 

62def register_event(event, *args, **kwargs): 

63 """Register the event with any interested plugins. 

64 

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) 

69 

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

74 

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 

80 

81 logger.debug("Registering callback for plugin '%s'", plugin.slug) 

82 

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 

87 

88 # Offload a separate task for each plugin 

89 offload_task( 

90 process_event, plugin.slug, event, *args, group='plugin', **kwargs 

91 ) 

92 

93 

94@tracer.start_as_current_span('process_event') 

95def process_event(plugin_slug, event, *args, **kwargs): 

96 """Respond to a triggered event. 

97 

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) 

102 

103 if plugin is None: # pragma: no cover 

104 logger.error("Could not find matching active plugin for '%s'", plugin_slug) 

105 return 

106 

107 logger.debug("Plugin '%s' is processing triggered event '%s'", plugin_slug, event) 

108 

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 

116 

117 

118def allow_table_event(table_name): 

119 """Determine if an automatic event should be fired for a given table. 

120 

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 

126 

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 

130 

131 table_name = table_name.lower().strip() 

132 

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 ] 

148 

149 if any(table_name.startswith(prefix) for prefix in ignore_prefixes): 

150 return False 

151 

152 ignore_tables = [ 

153 'common_notificationentry', 

154 'common_notificationmessage', 

155 'common_webhookendpoint', 

156 'common_webhookmessage', 

157 'part_partpricing', 

158 'part_partstocktake', 

159 ] 

160 

161 return table_name not in ignore_tables 

162 

163 

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 

168 

169 instance_id = getattr(instance, 'id', None) 

170 

171 if instance_id is None: 

172 return 

173 

174 if not allow_table_event(table): 

175 return 

176 

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

181 

182 

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 

187 

188 if not allow_table_event(table): 

189 return 

190 

191 instance_id = None 

192 

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) 

195 

196 trigger_event(f'{table}.deleted', model=sender.__name__, id=instance_id)