Merge pull request #2515 from SchrodingersGat/triggers

[Plugin] Triggered Events
This commit is contained in:
Oliver 2022-01-10 20:55:43 +11:00 committed by GitHub
commit a2ede73669
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 363 additions and 157 deletions

View File

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

View File

@ -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):
"""

View File

@ -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:

View File

@ -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
@ -43,6 +40,10 @@ database:
#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

View File

@ -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):
"""

View File

@ -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

View File

@ -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

177
InvenTree/plugin/events.py Normal file
View 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,
)

View File

@ -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

View File

@ -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',
]

View File

@ -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

View File

@ -56,6 +56,7 @@ class PluginsRegistry:
# integration specific
self.installed_apps = [] # Holds all added plugin_paths
# mixins
self.mixins_settings = {}

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

View File

@ -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',
},
}

View File

@ -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
"""

View File

@ -426,6 +426,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'parent',
'pathstring',
'items',
'owner',
]

View File

@ -20,6 +20,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% 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_NAVIGATION" icon="fa-sitemap" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}

View File

@ -90,7 +90,7 @@
</td>
</tr>
<tr>
<td></td>
<td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Installation path" %}</td>
<td>{{ plugin.package_path }}</td>
</tr>

View File

@ -12,7 +12,7 @@
<table class='table table-striped table-condensed'>
<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_DEBUG_MODE" icon="fa-laptop-code" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %}

View File

@ -11,7 +11,6 @@
<table class='table table-striped table-condensed'>
<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_STALE_DAYS" icon="fa-calendar" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}

View File

@ -214,88 +214,6 @@
</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='panel-heading'>
<div class='d-flex flex-wrap'>

View File

@ -345,6 +345,12 @@ function editPart(pk) {
// Launch form to duplicate a part
function duplicatePart(pk, options={}) {
var title = '{% trans "Duplicate Part" %}';
if (options.variant) {
title = '{% trans "Create Part Variant" %}';
}
// First we need all the part information
inventreeGet(`/api/part/${pk}/`, {}, {
@ -372,7 +378,7 @@ function duplicatePart(pk, options={}) {
method: 'POST',
fields: fields,
groups: partGroups(),
title: '{% trans "Duplicate Part" %}',
title: title,
data: data,
onSuccess: function(data) {
// Follow the new part

View File

@ -111,12 +111,17 @@ function stockLocationFields(options={}) {
},
name: {},
description: {},
owner: {},
};
if (options.parent) {
fields.parent.value = options.parent;
}
if (!global_settings.STOCK_OWNERSHIP_CONTROL) {
delete fields['owner'];
}
return fields;
}
@ -130,6 +135,8 @@ function editStockLocation(pk, options={}) {
options.fields = stockLocationFields(options);
options.title = '{% trans "Edit Stock Location" %}';
constructForm(url, options);
}

View File

@ -108,7 +108,7 @@
<ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'>
{% if user.is_authenticated %}
{% 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 %}
<li><a class='dropdown-item' href="{% url 'account_logout' %}"><span class="fas fa-sign-out-alt"></span> {% trans "Logout" %}</a></li>
{% else %}