mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	Merge pull request #2515 from SchrodingersGat/triggers
[Plugin] Triggered Events
This commit is contained in:
		| @@ -64,9 +64,7 @@ def offload_task(taskname, *args, force_sync=False, **kwargs): | ||||
|  | ||||
|     try: | ||||
|         from django_q.tasks import AsyncTask | ||||
|     except (AppRegistryNotReady): | ||||
|         logger.warning("Could not offload task - app registry not ready") | ||||
|         return | ||||
|  | ||||
|         import importlib | ||||
|         from InvenTree.status import is_worker_running | ||||
|  | ||||
| @@ -110,6 +108,12 @@ def offload_task(taskname, *args, force_sync=False, **kwargs): | ||||
|             # Workers are not running: run it as synchronous task | ||||
|             _func(*args, **kwargs) | ||||
|  | ||||
|     except (AppRegistryNotReady): | ||||
|         logger.warning(f"Could not offload task '{taskname}' - app registry not ready") | ||||
|         return | ||||
|     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 %} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user