mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2515 from SchrodingersGat/triggers
[Plugin] Triggered Events
This commit is contained in:
commit
a2ede73669
@ -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 %}
|
||||||
|
Loading…
Reference in New Issue
Block a user