mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge pull request #2515 from SchrodingersGat/triggers
[Plugin] Triggered Events
This commit is contained in:
		| @@ -64,51 +64,55 @@ def offload_task(taskname, *args, force_sync=False, **kwargs): | |||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         from django_q.tasks import AsyncTask |         from django_q.tasks import AsyncTask | ||||||
|  |  | ||||||
|  |         import importlib | ||||||
|  |         from InvenTree.status import is_worker_running | ||||||
|  |  | ||||||
|  |         if is_worker_running() and not force_sync: | ||||||
|  |             # Running as asynchronous task | ||||||
|  |             try: | ||||||
|  |                 task = AsyncTask(taskname, *args, **kwargs) | ||||||
|  |                 task.run() | ||||||
|  |             except ImportError: | ||||||
|  |                 logger.warning(f"WARNING: '{taskname}' not started - Function not found") | ||||||
|  |         else: | ||||||
|  |             # Split path | ||||||
|  |             try: | ||||||
|  |                 app, mod, func = taskname.split('.') | ||||||
|  |                 app_mod = app + '.' + mod | ||||||
|  |             except ValueError: | ||||||
|  |                 logger.warning(f"WARNING: '{taskname}' not started - Malformed function path") | ||||||
|  |                 return | ||||||
|  |  | ||||||
|  |             # Import module from app | ||||||
|  |             try: | ||||||
|  |                 _mod = importlib.import_module(app_mod) | ||||||
|  |             except ModuleNotFoundError: | ||||||
|  |                 logger.warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'") | ||||||
|  |                 return | ||||||
|  |  | ||||||
|  |             # Retrieve function | ||||||
|  |             try: | ||||||
|  |                 _func = getattr(_mod, func) | ||||||
|  |             except AttributeError: | ||||||
|  |                 # getattr does not work for local import | ||||||
|  |                 _func = None | ||||||
|  |  | ||||||
|  |             try: | ||||||
|  |                 if not _func: | ||||||
|  |                     _func = eval(func) | ||||||
|  |             except NameError: | ||||||
|  |                 logger.warning(f"WARNING: '{taskname}' not started - No function named '{func}'") | ||||||
|  |                 return | ||||||
|  |  | ||||||
|  |             # Workers are not running: run it as synchronous task | ||||||
|  |             _func(*args, **kwargs) | ||||||
|  |  | ||||||
|     except (AppRegistryNotReady): |     except (AppRegistryNotReady): | ||||||
|         logger.warning("Could not offload task - app registry not ready") |         logger.warning(f"Could not offload task '{taskname}' - app registry not ready") | ||||||
|         return |         return | ||||||
|     import importlib |     except (OperationalError, ProgrammingError): | ||||||
|     from InvenTree.status import is_worker_running |         logger.warning(f"Could not offload task '{taskname}' - database not ready") | ||||||
|  |  | ||||||
|     if is_worker_running() and not force_sync: |  | ||||||
|         # Running as asynchronous task |  | ||||||
|         try: |  | ||||||
|             task = AsyncTask(taskname, *args, **kwargs) |  | ||||||
|             task.run() |  | ||||||
|         except ImportError: |  | ||||||
|             logger.warning(f"WARNING: '{taskname}' not started - Function not found") |  | ||||||
|     else: |  | ||||||
|         # Split path |  | ||||||
|         try: |  | ||||||
|             app, mod, func = taskname.split('.') |  | ||||||
|             app_mod = app + '.' + mod |  | ||||||
|         except ValueError: |  | ||||||
|             logger.warning(f"WARNING: '{taskname}' not started - Malformed function path") |  | ||||||
|             return |  | ||||||
|  |  | ||||||
|         # Import module from app |  | ||||||
|         try: |  | ||||||
|             _mod = importlib.import_module(app_mod) |  | ||||||
|         except ModuleNotFoundError: |  | ||||||
|             logger.warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'") |  | ||||||
|             return |  | ||||||
|  |  | ||||||
|         # Retrieve function |  | ||||||
|         try: |  | ||||||
|             _func = getattr(_mod, func) |  | ||||||
|         except AttributeError: |  | ||||||
|             # getattr does not work for local import |  | ||||||
|             _func = None |  | ||||||
|  |  | ||||||
|         try: |  | ||||||
|             if not _func: |  | ||||||
|                 _func = eval(func) |  | ||||||
|         except NameError: |  | ||||||
|             logger.warning(f"WARNING: '{taskname}' not started - No function named '{func}'") |  | ||||||
|             return |  | ||||||
|  |  | ||||||
|         # Workers are not running: run it as synchronous task |  | ||||||
|         _func(*args, **kwargs) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def heartbeat(): | def heartbeat(): | ||||||
|   | |||||||
| @@ -36,6 +36,8 @@ import InvenTree.fields | |||||||
| import InvenTree.helpers | import InvenTree.helpers | ||||||
| import InvenTree.tasks | import InvenTree.tasks | ||||||
|  |  | ||||||
|  | from plugin.events import trigger_event | ||||||
|  |  | ||||||
| from part import models as PartModels | from part import models as PartModels | ||||||
| from stock import models as StockModels | from stock import models as StockModels | ||||||
| from users import models as UserModels | from users import models as UserModels | ||||||
| @@ -585,6 +587,9 @@ class Build(MPTTModel, ReferenceIndexingMixin): | |||||||
|         # which point to thisFcan Build Order |         # which point to thisFcan Build Order | ||||||
|         self.allocated_stock.all().delete() |         self.allocated_stock.all().delete() | ||||||
|  |  | ||||||
|  |         # Register an event | ||||||
|  |         trigger_event('build.completed', id=self.pk) | ||||||
|  |  | ||||||
|     @transaction.atomic |     @transaction.atomic | ||||||
|     def cancelBuild(self, user): |     def cancelBuild(self, user): | ||||||
|         """ Mark the Build as CANCELLED |         """ Mark the Build as CANCELLED | ||||||
| @@ -604,6 +609,8 @@ class Build(MPTTModel, ReferenceIndexingMixin): | |||||||
|         self.status = BuildStatus.CANCELLED |         self.status = BuildStatus.CANCELLED | ||||||
|         self.save() |         self.save() | ||||||
|  |  | ||||||
|  |         trigger_event('build.cancelled', id=self.pk) | ||||||
|  |  | ||||||
|     @transaction.atomic |     @transaction.atomic | ||||||
|     def unallocateStock(self, bom_item=None, output=None): |     def unallocateStock(self, bom_item=None, output=None): | ||||||
|         """ |         """ | ||||||
|   | |||||||
| @@ -872,13 +872,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): | |||||||
|             'validator': bool, |             'validator': bool, | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|         'STOCK_GROUP_BY_PART': { |  | ||||||
|             'name': _('Group by Part'), |  | ||||||
|             'description': _('Group stock items by part reference in table views'), |  | ||||||
|             'default': True, |  | ||||||
|             'validator': bool, |  | ||||||
|         }, |  | ||||||
|  |  | ||||||
|         'BUILDORDER_REFERENCE_PREFIX': { |         'BUILDORDER_REFERENCE_PREFIX': { | ||||||
|             'name': _('Build Order Reference Prefix'), |             'name': _('Build Order Reference Prefix'), | ||||||
|             'description': _('Prefix value for build order reference'), |             'description': _('Prefix value for build order reference'), | ||||||
| @@ -957,6 +950,8 @@ class InvenTreeSetting(BaseInvenTreeSetting): | |||||||
|             'default': False, |             'default': False, | ||||||
|             'validator': bool, |             'validator': bool, | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|  |         # Settings for plugin mixin features | ||||||
|         'ENABLE_PLUGINS_URL': { |         'ENABLE_PLUGINS_URL': { | ||||||
|             'name': _('Enable URL integration'), |             'name': _('Enable URL integration'), | ||||||
|             'description': _('Enable plugins to add URL routes'), |             'description': _('Enable plugins to add URL routes'), | ||||||
| @@ -984,7 +979,14 @@ class InvenTreeSetting(BaseInvenTreeSetting): | |||||||
|             'default': False, |             'default': False, | ||||||
|             'validator': bool, |             'validator': bool, | ||||||
|             'requires_restart': True, |             'requires_restart': True, | ||||||
|         } |         }, | ||||||
|  |         'ENABLE_PLUGINS_EVENTS': { | ||||||
|  |             'name': _('Enable event integration'), | ||||||
|  |             'description': _('Enable plugins to respond to internal events'), | ||||||
|  |             'default': False, | ||||||
|  |             'validator': bool, | ||||||
|  |             'requires_restart': True, | ||||||
|  |         }, | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|   | |||||||
| @@ -14,18 +14,15 @@ database: | |||||||
|  |  | ||||||
|   # --- Available options: --- |   # --- Available options: --- | ||||||
|   # ENGINE: Database engine. Selection from: |   # ENGINE: Database engine. Selection from: | ||||||
|   #         - sqlite3 |  | ||||||
|   #         - mysql |   #         - mysql | ||||||
|   #         - postgresql |   #         - postgresql | ||||||
|  |   #         - sqlite3 | ||||||
|   # NAME: Database name |   # NAME: Database name | ||||||
|   # USER: Database username (if required) |   # USER: Database username (if required) | ||||||
|   # PASSWORD: Database password (if required) |   # PASSWORD: Database password (if required) | ||||||
|   # HOST: Database host address (if required) |   # HOST: Database host address (if required) | ||||||
|   # PORT: Database host port (if required) |   # PORT: Database host port (if required) | ||||||
|  |  | ||||||
|   # --- Example Configuration - sqlite3 --- |  | ||||||
|   # ENGINE: sqlite3 |  | ||||||
|   # NAME: '/home/inventree/database.sqlite3' |  | ||||||
|  |  | ||||||
|   # --- Example Configuration - MySQL --- |   # --- Example Configuration - MySQL --- | ||||||
|   #ENGINE: mysql |   #ENGINE: mysql | ||||||
| @@ -42,6 +39,10 @@ database: | |||||||
|   #PASSWORD: inventree_password |   #PASSWORD: inventree_password | ||||||
|   #HOST: 'localhost' |   #HOST: 'localhost' | ||||||
|   #PORT: '5432' |   #PORT: '5432' | ||||||
|  |    | ||||||
|  |   # --- Example Configuration - sqlite3 --- | ||||||
|  |   # ENGINE: sqlite3 | ||||||
|  |   # NAME: '/home/inventree/database.sqlite3' | ||||||
|  |  | ||||||
| # Select default system language (default is 'en-us') | # Select default system language (default is 'en-us') | ||||||
| language: en-us | language: en-us | ||||||
|   | |||||||
| @@ -11,6 +11,7 @@ from decimal import Decimal | |||||||
| from django.db import models, transaction | from django.db import models, transaction | ||||||
| from django.db.models import Q, F, Sum | from django.db.models import Q, F, Sum | ||||||
| from django.db.models.functions import Coalesce | from django.db.models.functions import Coalesce | ||||||
|  |  | ||||||
| from django.core.validators import MinValueValidator | from django.core.validators import MinValueValidator | ||||||
| from django.core.exceptions import ValidationError | from django.core.exceptions import ValidationError | ||||||
| from django.contrib.auth.models import User | from django.contrib.auth.models import User | ||||||
| @@ -24,6 +25,7 @@ from users import models as UserModels | |||||||
| from part import models as PartModels | from part import models as PartModels | ||||||
| from stock import models as stock_models | from stock import models as stock_models | ||||||
| from company.models import Company, SupplierPart | from company.models import Company, SupplierPart | ||||||
|  | from plugin.events import trigger_event | ||||||
|  |  | ||||||
| from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField | from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField | ||||||
| from InvenTree.helpers import decimal2string, increment, getSetting | from InvenTree.helpers import decimal2string, increment, getSetting | ||||||
| @@ -317,6 +319,8 @@ class PurchaseOrder(Order): | |||||||
|             self.issue_date = datetime.now().date() |             self.issue_date = datetime.now().date() | ||||||
|             self.save() |             self.save() | ||||||
|  |  | ||||||
|  |             trigger_event('purchaseorder.placed', id=self.pk) | ||||||
|  |  | ||||||
|     @transaction.atomic |     @transaction.atomic | ||||||
|     def complete_order(self): |     def complete_order(self): | ||||||
|         """ Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """ |         """ Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """ | ||||||
| @@ -326,6 +330,8 @@ class PurchaseOrder(Order): | |||||||
|             self.complete_date = datetime.now().date() |             self.complete_date = datetime.now().date() | ||||||
|             self.save() |             self.save() | ||||||
|  |  | ||||||
|  |             trigger_event('purchaseorder.completed', id=self.pk) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def is_overdue(self): |     def is_overdue(self): | ||||||
|         """ |         """ | ||||||
| @@ -356,6 +362,8 @@ class PurchaseOrder(Order): | |||||||
|             self.status = PurchaseOrderStatus.CANCELLED |             self.status = PurchaseOrderStatus.CANCELLED | ||||||
|             self.save() |             self.save() | ||||||
|  |  | ||||||
|  |             trigger_event('purchaseorder.cancelled', id=self.pk) | ||||||
|  |  | ||||||
|     def pending_line_items(self): |     def pending_line_items(self): | ||||||
|         """ Return a list of pending line items for this order. |         """ Return a list of pending line items for this order. | ||||||
|         Any line item where 'received' < 'quantity' will be returned. |         Any line item where 'received' < 'quantity' will be returned. | ||||||
| @@ -667,6 +675,8 @@ class SalesOrder(Order): | |||||||
|  |  | ||||||
|         self.save() |         self.save() | ||||||
|  |  | ||||||
|  |         trigger_event('salesorder.completed', id=self.pk) | ||||||
|  |  | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|     def can_cancel(self): |     def can_cancel(self): | ||||||
| @@ -698,6 +708,8 @@ class SalesOrder(Order): | |||||||
|             for allocation in line.allocations.all(): |             for allocation in line.allocations.all(): | ||||||
|                 allocation.delete() |                 allocation.delete() | ||||||
|  |  | ||||||
|  |         trigger_event('salesorder.cancelled', id=self.pk) | ||||||
|  |  | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @@ -1104,6 +1116,8 @@ class SalesOrderShipment(models.Model): | |||||||
|  |  | ||||||
|         self.save() |         self.save() | ||||||
|  |  | ||||||
|  |         trigger_event('salesordershipment.completed', id=self.pk) | ||||||
|  |  | ||||||
|  |  | ||||||
| class SalesOrderAllocation(models.Model): | class SalesOrderAllocation(models.Model): | ||||||
|     """ |     """ | ||||||
|   | |||||||
| @@ -1980,10 +1980,10 @@ class Part(MPTTModel): | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def attachment_count(self): |     def attachment_count(self): | ||||||
|         """ Count the number of attachments for this part. |         """ | ||||||
|  |         Count the number of attachments for this part. | ||||||
|         If the part is a variant of a template part, |         If the part is a variant of a template part, | ||||||
|         include the number of attachments for the template part. |         include the number of attachments for the template part. | ||||||
|  |  | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         return self.part_attachments.count() |         return self.part_attachments.count() | ||||||
| @@ -2181,7 +2181,9 @@ def after_save_part(sender, instance: Part, created, **kwargs): | |||||||
|     Function to be executed after a Part is saved |     Function to be executed after a Part is saved | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     if not created: |     if created: | ||||||
|  |         pass | ||||||
|  |     else: | ||||||
|         # Check part stock only if we are *updating* the part (not creating it) |         # Check part stock only if we are *updating* the part (not creating it) | ||||||
|  |  | ||||||
|         # Run this check in the background |         # Run this check in the background | ||||||
|   | |||||||
| @@ -82,6 +82,7 @@ class ScheduleMixin: | |||||||
|  |  | ||||||
|     ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] |     ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] | ||||||
|  |  | ||||||
|  |     # Override this in subclass model | ||||||
|     SCHEDULED_TASKS = {} |     SCHEDULED_TASKS = {} | ||||||
|  |  | ||||||
|     class MixinMeta: |     class MixinMeta: | ||||||
| @@ -182,6 +183,25 @@ class ScheduleMixin: | |||||||
|             logger.warning("unregister_tasks failed, database not ready") |             logger.warning("unregister_tasks failed, database not ready") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EventMixin: | ||||||
|  |     """ | ||||||
|  |     Mixin that provides support for responding to triggered events. | ||||||
|  |  | ||||||
|  |     Implementing classes must provide a "process_event" function: | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def process_event(self, event, *args, **kwargs): | ||||||
|  |         # Default implementation does not do anything | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     class MixinMeta: | ||||||
|  |         MIXIN_NAME = 'Events' | ||||||
|  |  | ||||||
|  |     def __init__(self): | ||||||
|  |         super().__init__() | ||||||
|  |         self.add_mixin('events', True, __class__) | ||||||
|  |  | ||||||
|  |  | ||||||
| class UrlsMixin: | class UrlsMixin: | ||||||
|     """ |     """ | ||||||
|     Mixin that enables custom URLs for the plugin |     Mixin that enables custom URLs for the plugin | ||||||
|   | |||||||
							
								
								
									
										177
									
								
								InvenTree/plugin/events.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								InvenTree/plugin/events.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,177 @@ | |||||||
|  | """ | ||||||
|  | Functions for triggering and responding to server side events | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | # -*- coding: utf-8 -*- | ||||||
|  | from __future__ import unicode_literals | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  |  | ||||||
|  | from django.conf import settings | ||||||
|  | from django.db import transaction | ||||||
|  | from django.db.models.signals import post_save, post_delete | ||||||
|  | from django.dispatch.dispatcher import receiver | ||||||
|  |  | ||||||
|  | from common.models import InvenTreeSetting | ||||||
|  |  | ||||||
|  | from InvenTree.ready import canAppAccessDatabase | ||||||
|  | from InvenTree.tasks import offload_task | ||||||
|  |  | ||||||
|  | from plugin.registry import plugin_registry | ||||||
|  |  | ||||||
|  |  | ||||||
|  | logger = logging.getLogger('inventree') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def trigger_event(event, *args, **kwargs): | ||||||
|  |     """ | ||||||
|  |     Trigger an event with optional arguments. | ||||||
|  |  | ||||||
|  |     This event will be stored in the database, | ||||||
|  |     and the worker will respond to it later on. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     if not canAppAccessDatabase(): | ||||||
|  |         logger.debug(f"Ignoring triggered event '{event}' - database not ready") | ||||||
|  |         return | ||||||
|  |  | ||||||
|  |     logger.debug(f"Event triggered: '{event}'") | ||||||
|  |  | ||||||
|  |     offload_task( | ||||||
|  |         'plugin.events.register_event', | ||||||
|  |         event, | ||||||
|  |         *args, | ||||||
|  |         **kwargs | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def register_event(event, *args, **kwargs): | ||||||
|  |     """ | ||||||
|  |     Register the event with any interested plugins. | ||||||
|  |  | ||||||
|  |     Note: This function is processed by the background worker, | ||||||
|  |     as it performs multiple database access operations. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     logger.debug(f"Registering triggered event: '{event}'") | ||||||
|  |  | ||||||
|  |     # Determine if there are any plugins which are interested in responding | ||||||
|  |     if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'): | ||||||
|  |  | ||||||
|  |         with transaction.atomic(): | ||||||
|  |  | ||||||
|  |             for slug, plugin in plugin_registry.plugins.items(): | ||||||
|  |  | ||||||
|  |                 if plugin.mixin_enabled('events'): | ||||||
|  |  | ||||||
|  |                     config = plugin.plugin_config() | ||||||
|  |  | ||||||
|  |                     if config and config.active: | ||||||
|  |  | ||||||
|  |                         logger.debug(f"Registering callback for plugin '{slug}'") | ||||||
|  |  | ||||||
|  |                         # Offload a separate task for each plugin | ||||||
|  |                         offload_task( | ||||||
|  |                             'plugin.events.process_event', | ||||||
|  |                             slug, | ||||||
|  |                             event, | ||||||
|  |                             *args, | ||||||
|  |                             **kwargs | ||||||
|  |                         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def process_event(plugin_slug, event, *args, **kwargs): | ||||||
|  |     """ | ||||||
|  |     Respond to a triggered event. | ||||||
|  |  | ||||||
|  |     This function is run by the background worker process. | ||||||
|  |  | ||||||
|  |     This function may queue multiple functions to be handled by the background worker. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     logger.info(f"Plugin '{plugin_slug}' is processing triggered event '{event}'") | ||||||
|  |  | ||||||
|  |     plugin = plugin_registry.plugins[plugin_slug] | ||||||
|  |  | ||||||
|  |     plugin.process_event(event, *args, **kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def allow_table_event(table_name): | ||||||
|  |     """ | ||||||
|  |     Determine if an automatic event should be fired for a given table. | ||||||
|  |     We *do not* want events to be fired for some tables! | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     table_name = table_name.lower().strip() | ||||||
|  |  | ||||||
|  |     # Ignore any tables which start with these prefixes | ||||||
|  |     ignore_prefixes = [ | ||||||
|  |         'account_', | ||||||
|  |         'auth_', | ||||||
|  |         'authtoken_', | ||||||
|  |         'django_', | ||||||
|  |         'error_', | ||||||
|  |         'exchange_', | ||||||
|  |         'otp_', | ||||||
|  |         'plugin_', | ||||||
|  |         'socialaccount_', | ||||||
|  |         'user_', | ||||||
|  |         'users_', | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     if any([table_name.startswith(prefix) for prefix in ignore_prefixes]): | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     ignore_tables = [ | ||||||
|  |         'common_notificationentry', | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     if table_name in ignore_tables: | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     return True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(post_save) | ||||||
|  | def after_save(sender, instance, created, **kwargs): | ||||||
|  |     """ | ||||||
|  |     Trigger an event whenever a database entry is saved | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     table = sender.objects.model._meta.db_table | ||||||
|  |  | ||||||
|  |     if not allow_table_event(table): | ||||||
|  |         return | ||||||
|  |  | ||||||
|  |     if created: | ||||||
|  |         trigger_event( | ||||||
|  |             'instance.created', | ||||||
|  |             id=instance.id, | ||||||
|  |             model=sender.__name__, | ||||||
|  |             table=table, | ||||||
|  |         ) | ||||||
|  |     else: | ||||||
|  |         trigger_event( | ||||||
|  |             'instance.saved', | ||||||
|  |             id=instance.id, | ||||||
|  |             model=sender.__name__, | ||||||
|  |             table=table, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(post_delete) | ||||||
|  | def after_delete(sender, instance, **kwargs): | ||||||
|  |     """ | ||||||
|  |     Trigger an event whenever a database entry is deleted | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     table = sender.objects.model._meta.db_table | ||||||
|  |  | ||||||
|  |     if not allow_table_event(table): | ||||||
|  |         return | ||||||
|  |  | ||||||
|  |     trigger_event( | ||||||
|  |         'instance.deleted', | ||||||
|  |         model=sender.__name__, | ||||||
|  |         table=table, | ||||||
|  |     ) | ||||||
| @@ -176,6 +176,11 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin): | |||||||
|         """check if mixin is enabled and ready""" |         """check if mixin is enabled and ready""" | ||||||
|         if self.mixin(key): |         if self.mixin(key): | ||||||
|             fnc_name = self._mixins.get(key) |             fnc_name = self._mixins.get(key) | ||||||
|  |  | ||||||
|  |             # Allow for simple case where the mixin is "always" ready | ||||||
|  |             if fnc_name is True: | ||||||
|  |                 return True | ||||||
|  |  | ||||||
|             return getattr(self, fnc_name, True) |             return getattr(self, fnc_name, True) | ||||||
|         return False |         return False | ||||||
|     # endregion |     # endregion | ||||||
|   | |||||||
| @@ -2,13 +2,14 @@ | |||||||
| Utility class to enable simpler imports | Utility class to enable simpler imports | ||||||
| """ | """ | ||||||
|  |  | ||||||
| from ..builtin.integration.mixins import AppMixin, SettingsMixin, ScheduleMixin, UrlsMixin, NavigationMixin, APICallMixin | from ..builtin.integration.mixins import APICallMixin, AppMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin | ||||||
|  |  | ||||||
| __all__ = [ | __all__ = [ | ||||||
|  |     'APICallMixin', | ||||||
|     'AppMixin', |     'AppMixin', | ||||||
|  |     'EventMixin', | ||||||
|     'NavigationMixin', |     'NavigationMixin', | ||||||
|     'ScheduleMixin', |     'ScheduleMixin', | ||||||
|     'SettingsMixin', |     'SettingsMixin', | ||||||
|     'UrlsMixin', |     'UrlsMixin', | ||||||
|     'APICallMixin', |  | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -63,3 +63,15 @@ class InvenTreePlugin(): | |||||||
|                 raise error |                 raise error | ||||||
|  |  | ||||||
|         return cfg |         return cfg | ||||||
|  |  | ||||||
|  |     def is_active(self): | ||||||
|  |         """ | ||||||
|  |         Return True if this plugin is currently active | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         cfg = self.plugin_config() | ||||||
|  |  | ||||||
|  |         if cfg: | ||||||
|  |             return cfg.active | ||||||
|  |         else: | ||||||
|  |             return False | ||||||
|   | |||||||
| @@ -56,6 +56,7 @@ class PluginsRegistry: | |||||||
|  |  | ||||||
|         # integration specific |         # integration specific | ||||||
|         self.installed_apps = []         # Holds all added plugin_paths |         self.installed_apps = []         # Holds all added plugin_paths | ||||||
|  |  | ||||||
|         # mixins |         # mixins | ||||||
|         self.mixins_settings = {} |         self.mixins_settings = {} | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								InvenTree/plugin/samples/integration/event_sample.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								InvenTree/plugin/samples/integration/event_sample.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | """ | ||||||
|  | Sample plugin which responds to events | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | from plugin import IntegrationPluginBase | ||||||
|  | from plugin.mixins import EventMixin | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EventPluginSample(EventMixin, IntegrationPluginBase): | ||||||
|  |     """ | ||||||
|  |     A sample plugin which provides supports for triggered events | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     PLUGIN_NAME = "EventPlugin" | ||||||
|  |     PLUGIN_SLUG = "event" | ||||||
|  |     PLUGIN_TITLE = "Triggered Events" | ||||||
|  |  | ||||||
|  |     def process_event(self, event, *args, **kwargs): | ||||||
|  |         """ Custom event processing """ | ||||||
|  |  | ||||||
|  |         print(f"Processing triggered event: '{event}'") | ||||||
|  |         print("args:", str(args)) | ||||||
|  |         print("kwargs:", str(kwargs)) | ||||||
| @@ -15,10 +15,6 @@ def print_world(): | |||||||
|     print("World") |     print("World") | ||||||
|  |  | ||||||
|  |  | ||||||
| def fail_task(): |  | ||||||
|     raise ValueError("This task should fail!") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): | class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): | ||||||
|     """ |     """ | ||||||
|     A sample plugin which provides support for scheduled tasks |     A sample plugin which provides support for scheduled tasks | ||||||
| @@ -32,14 +28,10 @@ class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): | |||||||
|         'hello': { |         'hello': { | ||||||
|             'func': 'plugin.samples.integration.scheduled_task.print_hello', |             'func': 'plugin.samples.integration.scheduled_task.print_hello', | ||||||
|             'schedule': 'I', |             'schedule': 'I', | ||||||
|             'minutes': 5, |             'minutes': 45, | ||||||
|         }, |         }, | ||||||
|         'world': { |         'world': { | ||||||
|             'func': 'plugin.samples.integration.scheduled_task.print_hello', |             'func': 'plugin.samples.integration.scheduled_task.print_hello', | ||||||
|             'schedule': 'H', |             'schedule': 'H', | ||||||
|         }, |         }, | ||||||
|         'failure': { |  | ||||||
|             'func': 'plugin.samples.integration.scheduled_task.fail_task', |  | ||||||
|             'schedule': 'D', |  | ||||||
|         }, |  | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -35,6 +35,8 @@ import common.models | |||||||
| import report.models | import report.models | ||||||
| import label.models | import label.models | ||||||
|  |  | ||||||
|  | from plugin.events import trigger_event | ||||||
|  |  | ||||||
| from InvenTree.status_codes import StockStatus, StockHistoryCode | from InvenTree.status_codes import StockStatus, StockHistoryCode | ||||||
| from InvenTree.models import InvenTreeTree, InvenTreeAttachment | from InvenTree.models import InvenTreeTree, InvenTreeAttachment | ||||||
| from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField | from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField | ||||||
| @@ -718,6 +720,12 @@ class StockItem(MPTTModel): | |||||||
|             notes=notes, |             notes=notes, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         trigger_event( | ||||||
|  |             'stockitem.assignedtocustomer', | ||||||
|  |             id=self.id, | ||||||
|  |             customer=customer.id, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         # Return the reference to the stock item |         # Return the reference to the stock item | ||||||
|         return item |         return item | ||||||
|  |  | ||||||
| @@ -745,6 +753,11 @@ class StockItem(MPTTModel): | |||||||
|         self.customer = None |         self.customer = None | ||||||
|         self.location = location |         self.location = location | ||||||
|  |  | ||||||
|  |         trigger_event( | ||||||
|  |             'stockitem.returnedfromcustomer', | ||||||
|  |             id=self.id, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         self.save() |         self.save() | ||||||
|  |  | ||||||
|     # If stock item is incoming, an (optional) ETA field |     # If stock item is incoming, an (optional) ETA field | ||||||
| @@ -1786,7 +1799,7 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs): | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log') | @receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log') | ||||||
| def after_save_stock_item(sender, instance: StockItem, **kwargs): | def after_save_stock_item(sender, instance: StockItem, created, **kwargs): | ||||||
|     """ |     """ | ||||||
|     Hook function to be executed after StockItem object is saved/updated |     Hook function to be executed after StockItem object is saved/updated | ||||||
|     """ |     """ | ||||||
|   | |||||||
| @@ -426,6 +426,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer): | |||||||
|             'parent', |             'parent', | ||||||
|             'pathstring', |             'pathstring', | ||||||
|             'items', |             'items', | ||||||
|  |             'owner', | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -20,6 +20,7 @@ | |||||||
| <table class='table table-striped table-condensed'> | <table class='table table-striped table-condensed'> | ||||||
|     <tbody> |     <tbody> | ||||||
|         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_SCHEDULE" icon="fa-calendar-alt" %} |         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_SCHEDULE" icon="fa-calendar-alt" %} | ||||||
|  |         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_EVENTS" icon="fa-reply-all" %} | ||||||
|         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %} |         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %} | ||||||
|         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %} |         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %} | ||||||
|         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %} |         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %} | ||||||
|   | |||||||
| @@ -90,7 +90,7 @@ | |||||||
|                 </td> |                 </td> | ||||||
|             </tr> |             </tr> | ||||||
|             <tr> |             <tr> | ||||||
|                 <td></td> |                 <td><span class='fas fa-sitemap'></span></td> | ||||||
|                 <td>{% trans "Installation path" %}</td> |                 <td>{% trans "Installation path" %}</td> | ||||||
|                 <td>{{ plugin.package_path }}</td> |                 <td>{{ plugin.package_path }}</td> | ||||||
|             </tr> |             </tr> | ||||||
|   | |||||||
| @@ -12,7 +12,7 @@ | |||||||
|  |  | ||||||
| <table class='table table-striped table-condensed'> | <table class='table table-striped table-condensed'> | ||||||
|     <tbody> |     <tbody> | ||||||
|         {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="file-pdf" %} |         {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="fa-file-pdf" %} | ||||||
|         {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %} |         {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %} | ||||||
|         {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %} |         {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %} | ||||||
|         {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %} |         {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %} | ||||||
|   | |||||||
| @@ -11,7 +11,6 @@ | |||||||
|  |  | ||||||
| <table class='table table-striped table-condensed'> | <table class='table table-striped table-condensed'> | ||||||
|     <tbody> |     <tbody> | ||||||
|         {% include "InvenTree/settings/setting.html" with key="STOCK_GROUP_BY_PART" icon="fa-layer-group" %} |  | ||||||
|         {% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %} |         {% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %} | ||||||
|         {% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %} |         {% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %} | ||||||
|         {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %} |         {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %} | ||||||
|   | |||||||
| @@ -214,88 +214,6 @@ | |||||||
|     </div> |     </div> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <div class='row'> |  | ||||||
|     <div class='panel-heading'> |  | ||||||
|         <h4>{% trans "Theme Settings" %}</h4> |  | ||||||
|     </div> |  | ||||||
|  |  | ||||||
|     <div class='col-sm-6'> |  | ||||||
|         <form action='{% url "settings-appearance" %}' method='post'> |  | ||||||
|             {% csrf_token %} |  | ||||||
|             <input name='next' type='hidden' value='{% url "settings" %}'> |  | ||||||
|             <label for='theme' class=' requiredField'> |  | ||||||
|                 {% trans "Select theme" %} |  | ||||||
|             </label> |  | ||||||
|             <div class='form-group input-group mb-3'> |  | ||||||
|                 <select id='theme' name='theme' class='select form-control'> |  | ||||||
|                     {% get_available_themes as themes %} |  | ||||||
|                     {% for theme in themes %} |  | ||||||
|                     <option value='{{ theme.key }}'>{{ theme.name }}</option> |  | ||||||
|                     {% endfor %} |  | ||||||
|                 </select> |  | ||||||
|                 <div class='input-group-append'> |  | ||||||
|                     <input type="submit" value="{% trans 'Set Theme' %}" class="btn btn-primary"> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|         </form> |  | ||||||
|     </div> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| <div class="row"> |  | ||||||
|     <div class='panel-heading'> |  | ||||||
|         <h4>{% trans "Language Settings" %}</h4> |  | ||||||
|     </div> |  | ||||||
|  |  | ||||||
|     <div class="col"> |  | ||||||
|         <form action="{% url 'set_language' %}" method="post"> |  | ||||||
|             {% csrf_token %} |  | ||||||
|             <input name="next" type="hidden" value="{% url 'settings' %}"> |  | ||||||
|             <label for='language' class=' requiredField'> |  | ||||||
|                 {% trans "Select language" %} |  | ||||||
|             </label> |  | ||||||
|             <div class='form-group input-group mb-3'> |  | ||||||
|                 <select name="language" class="select form-control w-25"> |  | ||||||
|                     {% get_current_language as LANGUAGE_CODE %} |  | ||||||
|                     {% get_available_languages as LANGUAGES %} |  | ||||||
|                     {% get_language_info_list for LANGUAGES as languages %} |  | ||||||
|                     {% if 'alllang' in request.GET %}{% define True as ALL_LANG %}{% endif %} |  | ||||||
|                     {% for language in languages %} |  | ||||||
|                         {% define language.code as lang_code %} |  | ||||||
|                         {% define locale_stats|keyvalue:lang_code as lang_translated %} |  | ||||||
|                         {% if lang_translated > 10 or lang_code == 'en' or lang_code == LANGUAGE_CODE %}{% define True as use_lang %}{% else %}{% define False as use_lang %}{% endif %} |  | ||||||
|                         {% if ALL_LANG or use_lang  %} |  | ||||||
|                         <option value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}> |  | ||||||
|                             {{ language.name_local }} ({{ lang_code }})  |  | ||||||
|                             {% if lang_translated %} |  | ||||||
|                                 {% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %} |  | ||||||
|                             {% else %} |  | ||||||
|                                 {% if lang_code == 'en' %}-{% else %}{% trans 'No translations available' %}{% endif %} |  | ||||||
|                             {% endif %} |  | ||||||
|                         </option> |  | ||||||
|                         {% endif %} |  | ||||||
|                     {% endfor %} |  | ||||||
|                 </select> |  | ||||||
|                 <div class='input-group-append'> |  | ||||||
|                     <input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary"> |  | ||||||
|                 </div> |  | ||||||
|                 <p>{% trans "Some languages are not complete" %} |  | ||||||
|                 {% if ALL_LANG %} |  | ||||||
|                 . <a href="{% url 'settings' %}">{% trans "Show only sufficent" %}</a> |  | ||||||
|                 {% else %} |  | ||||||
|                 and hidden. <a href="?alllang">{% trans "Show them too" %}</a> |  | ||||||
|                 {% endif %} |  | ||||||
|                 </p> |  | ||||||
|             </div> |  | ||||||
|     </form> |  | ||||||
|     </div> |  | ||||||
|     <div class="col-sm-6"> |  | ||||||
|         <h4>{% trans "Help the translation efforts!" %}</h4> |  | ||||||
|         <p>{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the |  | ||||||
|             InvenTree web application is <a href="{{link}}">community contributed via crowdin</a>. Contributions are |  | ||||||
|             welcomed and encouraged.{% endblocktrans %}</p> |  | ||||||
|     </div> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| <div class="row"> | <div class="row"> | ||||||
|     <div class='panel-heading'> |     <div class='panel-heading'> | ||||||
|         <div class='d-flex flex-wrap'> |         <div class='d-flex flex-wrap'> | ||||||
|   | |||||||
| @@ -345,6 +345,12 @@ function editPart(pk) { | |||||||
| // Launch form to duplicate a part | // Launch form to duplicate a part | ||||||
| function duplicatePart(pk, options={}) { | function duplicatePart(pk, options={}) { | ||||||
|  |  | ||||||
|  |     var title = '{% trans "Duplicate Part" %}'; | ||||||
|  |  | ||||||
|  |     if (options.variant) { | ||||||
|  |         title = '{% trans "Create Part Variant" %}'; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     // First we need all the part information |     // First we need all the part information | ||||||
|     inventreeGet(`/api/part/${pk}/`, {}, { |     inventreeGet(`/api/part/${pk}/`, {}, { | ||||||
|  |  | ||||||
| @@ -372,7 +378,7 @@ function duplicatePart(pk, options={}) { | |||||||
|                 method: 'POST', |                 method: 'POST', | ||||||
|                 fields: fields, |                 fields: fields, | ||||||
|                 groups: partGroups(), |                 groups: partGroups(), | ||||||
|                 title: '{% trans "Duplicate Part" %}', |                 title: title, | ||||||
|                 data: data, |                 data: data, | ||||||
|                 onSuccess: function(data) { |                 onSuccess: function(data) { | ||||||
|                     // Follow the new part |                     // Follow the new part | ||||||
|   | |||||||
| @@ -111,12 +111,17 @@ function stockLocationFields(options={}) { | |||||||
|         }, |         }, | ||||||
|         name: {}, |         name: {}, | ||||||
|         description: {}, |         description: {}, | ||||||
|  |         owner: {}, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     if (options.parent) { |     if (options.parent) { | ||||||
|         fields.parent.value = options.parent; |         fields.parent.value = options.parent; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     if (!global_settings.STOCK_OWNERSHIP_CONTROL) { | ||||||
|  |         delete fields['owner']; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return fields; |     return fields; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -130,6 +135,8 @@ function editStockLocation(pk, options={}) { | |||||||
|  |  | ||||||
|     options.fields = stockLocationFields(options); |     options.fields = stockLocationFields(options); | ||||||
|  |  | ||||||
|  |     options.title = '{% trans "Edit Stock Location" %}'; | ||||||
|  |  | ||||||
|     constructForm(url, options); |     constructForm(url, options); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -108,7 +108,7 @@ | |||||||
|         <ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'> |         <ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'> | ||||||
|           {% if user.is_authenticated %} |           {% if user.is_authenticated %} | ||||||
|           {% if user.is_staff and not demo %} |           {% if user.is_staff and not demo %} | ||||||
|           <li><a class='dropdown-item' href="/admin/"><span class="fas fa-user"></span> {% trans "Admin" %}</a></li> |           <li><a class='dropdown-item' href="/admin/"><span class="fas fa-user-shield"></span> {% trans "Admin" %}</a></li> | ||||||
|           {% endif %} |           {% endif %} | ||||||
|           <li><a class='dropdown-item' href="{% url 'account_logout' %}"><span class="fas fa-sign-out-alt"></span> {% trans "Logout" %}</a></li> |           <li><a class='dropdown-item' href="{% url 'account_logout' %}"><span class="fas fa-sign-out-alt"></span> {% trans "Logout" %}</a></li> | ||||||
|           {% else %} |           {% else %} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user