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:
|
||||
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():
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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
|
||||
|
@ -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
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"""
|
||||
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
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -56,6 +56,7 @@ class PluginsRegistry:
|
||||
|
||||
# integration specific
|
||||
self.installed_apps = [] # Holds all added plugin_paths
|
||||
|
||||
# mixins
|
||||
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")
|
||||
|
||||
|
||||
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',
|
||||
},
|
||||
}
|
||||
|
@ -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
|
||||
"""
|
||||
|
@ -426,6 +426,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'parent',
|
||||
'pathstring',
|
||||
'items',
|
||||
'owner',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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" %}
|
||||
|
@ -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>
|
||||
|
@ -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" %}
|
||||
|
@ -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" %}
|
||||
|
@ -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'>
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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 %}
|
||||
|
Loading…
Reference in New Issue
Block a user