diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 18c3bcc564..0a098e5f8c 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -64,51 +64,55 @@ def offload_task(taskname, *args, force_sync=False, **kwargs): try: 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): - logger.warning("Could not offload task - app registry not ready") + logger.warning(f"Could not offload task '{taskname}' - app registry not ready") return - 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 (OperationalError, ProgrammingError): + logger.warning(f"Could not offload task '{taskname}' - database not ready") def heartbeat(): diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index cd8f4df16f..8cad93352b 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -36,6 +36,8 @@ import InvenTree.fields import InvenTree.helpers import InvenTree.tasks +from plugin.events import trigger_event + from part import models as PartModels from stock import models as StockModels from users import models as UserModels @@ -585,6 +587,9 @@ class Build(MPTTModel, ReferenceIndexingMixin): # which point to thisFcan Build Order self.allocated_stock.all().delete() + # Register an event + trigger_event('build.completed', id=self.pk) + @transaction.atomic def cancelBuild(self, user): """ Mark the Build as CANCELLED @@ -604,6 +609,8 @@ class Build(MPTTModel, ReferenceIndexingMixin): self.status = BuildStatus.CANCELLED self.save() + trigger_event('build.cancelled', id=self.pk) + @transaction.atomic def unallocateStock(self, bom_item=None, output=None): """ diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index dee0eb2e8b..50c95966cc 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -872,13 +872,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): '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': { 'name': _('Build Order Reference Prefix'), 'description': _('Prefix value for build order reference'), @@ -957,6 +950,8 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': False, 'validator': bool, }, + + # Settings for plugin mixin features 'ENABLE_PLUGINS_URL': { 'name': _('Enable URL integration'), 'description': _('Enable plugins to add URL routes'), @@ -984,7 +979,14 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': False, 'validator': bool, '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: diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 3472a37d8e..d8f780bb36 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -14,18 +14,15 @@ database: # --- Available options: --- # ENGINE: Database engine. Selection from: - # - sqlite3 # - mysql # - postgresql + # - sqlite3 # NAME: Database name # USER: Database username (if required) # PASSWORD: Database password (if required) # HOST: Database host address (if required) # PORT: Database host port (if required) - # --- Example Configuration - sqlite3 --- - # ENGINE: sqlite3 - # NAME: '/home/inventree/database.sqlite3' # --- Example Configuration - MySQL --- #ENGINE: mysql @@ -42,6 +39,10 @@ database: #PASSWORD: inventree_password #HOST: 'localhost' #PORT: '5432' + + # --- Example Configuration - sqlite3 --- + # ENGINE: sqlite3 + # NAME: '/home/inventree/database.sqlite3' # Select default system language (default is 'en-us') language: en-us diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index e798ee4e30..a86c437e05 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -11,6 +11,7 @@ from decimal import Decimal from django.db import models, transaction from django.db.models import Q, F, Sum from django.db.models.functions import Coalesce + from django.core.validators import MinValueValidator from django.core.exceptions import ValidationError from django.contrib.auth.models import User @@ -24,6 +25,7 @@ from users import models as UserModels from part import models as PartModels from stock import models as stock_models from company.models import Company, SupplierPart +from plugin.events import trigger_event from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField from InvenTree.helpers import decimal2string, increment, getSetting @@ -317,6 +319,8 @@ class PurchaseOrder(Order): self.issue_date = datetime.now().date() self.save() + trigger_event('purchaseorder.placed', id=self.pk) + @transaction.atomic def complete_order(self): """ Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """ @@ -326,6 +330,8 @@ class PurchaseOrder(Order): self.complete_date = datetime.now().date() self.save() + trigger_event('purchaseorder.completed', id=self.pk) + @property def is_overdue(self): """ @@ -356,6 +362,8 @@ class PurchaseOrder(Order): self.status = PurchaseOrderStatus.CANCELLED self.save() + trigger_event('purchaseorder.cancelled', id=self.pk) + def pending_line_items(self): """ Return a list of pending line items for this order. Any line item where 'received' < 'quantity' will be returned. @@ -667,6 +675,8 @@ class SalesOrder(Order): self.save() + trigger_event('salesorder.completed', id=self.pk) + return True def can_cancel(self): @@ -698,6 +708,8 @@ class SalesOrder(Order): for allocation in line.allocations.all(): allocation.delete() + trigger_event('salesorder.cancelled', id=self.pk) + return True @property @@ -1104,6 +1116,8 @@ class SalesOrderShipment(models.Model): self.save() + trigger_event('salesordershipment.completed', id=self.pk) + class SalesOrderAllocation(models.Model): """ diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index ec76846a08..604d384a67 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1980,10 +1980,10 @@ class Part(MPTTModel): @property 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, include the number of attachments for the template part. - """ 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 """ - if not created: + if created: + pass + else: # Check part stock only if we are *updating* the part (not creating it) # Run this check in the background diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 7d257dbee5..586fc8a666 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -82,6 +82,7 @@ class ScheduleMixin: ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] + # Override this in subclass model SCHEDULED_TASKS = {} class MixinMeta: @@ -182,6 +183,25 @@ class ScheduleMixin: 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: """ Mixin that enables custom URLs for the plugin diff --git a/InvenTree/plugin/events.py b/InvenTree/plugin/events.py new file mode 100644 index 0000000000..f777621a45 --- /dev/null +++ b/InvenTree/plugin/events.py @@ -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, + ) diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py index b7ae7d1fc4..c00b81419d 100644 --- a/InvenTree/plugin/integration.py +++ b/InvenTree/plugin/integration.py @@ -176,6 +176,11 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin): """check if mixin is enabled and ready""" if self.mixin(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 False # endregion diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index 522afa22c4..ca5f0c615d 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -2,13 +2,14 @@ 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__ = [ + 'APICallMixin', 'AppMixin', + 'EventMixin', 'NavigationMixin', 'ScheduleMixin', 'SettingsMixin', 'UrlsMixin', - 'APICallMixin', ] diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index 35643b36c3..8842553ba7 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -63,3 +63,15 @@ class InvenTreePlugin(): raise error 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 diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index b7e37d22ba..8d04620120 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -56,6 +56,7 @@ class PluginsRegistry: # integration specific self.installed_apps = [] # Holds all added plugin_paths + # mixins self.mixins_settings = {} diff --git a/InvenTree/plugin/samples/integration/event_sample.py b/InvenTree/plugin/samples/integration/event_sample.py new file mode 100644 index 0000000000..dddb97da1d --- /dev/null +++ b/InvenTree/plugin/samples/integration/event_sample.py @@ -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)) diff --git a/InvenTree/plugin/samples/integration/scheduled_task.py b/InvenTree/plugin/samples/integration/scheduled_task.py index 5a8f866cd7..825dab134f 100644 --- a/InvenTree/plugin/samples/integration/scheduled_task.py +++ b/InvenTree/plugin/samples/integration/scheduled_task.py @@ -15,10 +15,6 @@ def print_world(): print("World") -def fail_task(): - raise ValueError("This task should fail!") - - class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): """ A sample plugin which provides support for scheduled tasks @@ -32,14 +28,10 @@ class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): 'hello': { 'func': 'plugin.samples.integration.scheduled_task.print_hello', 'schedule': 'I', - 'minutes': 5, + 'minutes': 45, }, 'world': { 'func': 'plugin.samples.integration.scheduled_task.print_hello', 'schedule': 'H', }, - 'failure': { - 'func': 'plugin.samples.integration.scheduled_task.fail_task', - 'schedule': 'D', - }, } diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index d302b1676c..140bd9c8e3 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -35,6 +35,8 @@ import common.models import report.models import label.models +from plugin.events import trigger_event + from InvenTree.status_codes import StockStatus, StockHistoryCode from InvenTree.models import InvenTreeTree, InvenTreeAttachment from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField @@ -718,6 +720,12 @@ class StockItem(MPTTModel): notes=notes, ) + trigger_event( + 'stockitem.assignedtocustomer', + id=self.id, + customer=customer.id, + ) + # Return the reference to the stock item return item @@ -745,6 +753,11 @@ class StockItem(MPTTModel): self.customer = None self.location = location + trigger_event( + 'stockitem.returnedfromcustomer', + id=self.id, + ) + self.save() # 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') -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 """ diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 1c4c8844ff..cdc844095b 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -426,6 +426,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer): 'parent', 'pathstring', 'items', + 'owner', ] diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html index 858d0f3ab9..7b428e161f 100644 --- a/InvenTree/templates/InvenTree/settings/plugin.html +++ b/InvenTree/templates/InvenTree/settings/plugin.html @@ -20,6 +20,7 @@
+ | {% trans "Installation path" %} | {{ plugin.package_path }} |