Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue2519

This commit is contained in:
Matthias 2022-01-10 23:48:43 +01:00
commit 3b6e31cd35
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
48 changed files with 10840 additions and 9725 deletions

View File

@ -64,51 +64,55 @@ def offload_task(taskname, *args, force_sync=False, **kwargs):
try:
from django_q.tasks import AsyncTask
import importlib
from InvenTree.status import is_worker_running
if is_worker_running() and not force_sync:
# Running as asynchronous task
try:
task = AsyncTask(taskname, *args, **kwargs)
task.run()
except ImportError:
logger.warning(f"WARNING: '{taskname}' not started - Function not found")
else:
# Split path
try:
app, mod, func = taskname.split('.')
app_mod = app + '.' + mod
except ValueError:
logger.warning(f"WARNING: '{taskname}' not started - Malformed function path")
return
# Import module from app
try:
_mod = importlib.import_module(app_mod)
except ModuleNotFoundError:
logger.warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'")
return
# Retrieve function
try:
_func = getattr(_mod, func)
except AttributeError:
# getattr does not work for local import
_func = None
try:
if not _func:
_func = eval(func)
except NameError:
logger.warning(f"WARNING: '{taskname}' not started - No function named '{func}'")
return
# Workers are not running: run it as synchronous task
_func(*args, **kwargs)
except (AppRegistryNotReady):
logger.warning("Could not offload task - app registry not ready")
logger.warning(f"Could not offload task '{taskname}' - app registry not ready")
return
import importlib
from InvenTree.status import is_worker_running
if is_worker_running() and not force_sync:
# Running as asynchronous task
try:
task = AsyncTask(taskname, *args, **kwargs)
task.run()
except ImportError:
logger.warning(f"WARNING: '{taskname}' not started - Function not found")
else:
# Split path
try:
app, mod, func = taskname.split('.')
app_mod = app + '.' + mod
except ValueError:
logger.warning(f"WARNING: '{taskname}' not started - Malformed function path")
return
# Import module from app
try:
_mod = importlib.import_module(app_mod)
except ModuleNotFoundError:
logger.warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'")
return
# Retrieve function
try:
_func = getattr(_mod, func)
except AttributeError:
# getattr does not work for local import
_func = None
try:
if not _func:
_func = eval(func)
except NameError:
logger.warning(f"WARNING: '{taskname}' not started - No function named '{func}'")
return
# Workers are not running: run it as synchronous task
_func(*args, **kwargs)
except (OperationalError, ProgrammingError):
logger.warning(f"Could not offload task '{taskname}' - database not ready")
def heartbeat():

View File

@ -36,6 +36,8 @@ import InvenTree.fields
import InvenTree.helpers
import InvenTree.tasks
from plugin.events import trigger_event
from part import models as PartModels
from stock import models as StockModels
from users import models as UserModels
@ -585,6 +587,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
# which point to thisFcan Build Order
self.allocated_stock.all().delete()
# Register an event
trigger_event('build.completed', id=self.pk)
@transaction.atomic
def cancelBuild(self, user):
""" Mark the Build as CANCELLED
@ -604,6 +609,8 @@ class Build(MPTTModel, ReferenceIndexingMixin):
self.status = BuildStatus.CANCELLED
self.save()
trigger_event('build.cancelled', id=self.pk)
@transaction.atomic
def unallocateStock(self, bom_item=None, output=None):
"""

View File

@ -872,13 +872,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
},
'STOCK_GROUP_BY_PART': {
'name': _('Group by Part'),
'description': _('Group stock items by part reference in table views'),
'default': True,
'validator': bool,
},
'BUILDORDER_REFERENCE_PREFIX': {
'name': _('Build Order Reference Prefix'),
'description': _('Prefix value for build order reference'),
@ -957,6 +950,8 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
# Settings for plugin mixin features
'ENABLE_PLUGINS_URL': {
'name': _('Enable URL integration'),
'description': _('Enable plugins to add URL routes'),
@ -984,7 +979,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
'requires_restart': True,
}
},
'ENABLE_PLUGINS_EVENTS': {
'name': _('Enable event integration'),
'description': _('Enable plugins to respond to internal events'),
'default': False,
'validator': bool,
'requires_restart': True,
},
}
class Meta:

View File

@ -14,18 +14,15 @@ database:
# --- Available options: ---
# ENGINE: Database engine. Selection from:
# - sqlite3
# - mysql
# - postgresql
# - sqlite3
# NAME: Database name
# USER: Database username (if required)
# PASSWORD: Database password (if required)
# HOST: Database host address (if required)
# PORT: Database host port (if required)
# --- Example Configuration - sqlite3 ---
# ENGINE: sqlite3
# NAME: '/home/inventree/database.sqlite3'
# --- Example Configuration - MySQL ---
#ENGINE: mysql
@ -42,6 +39,10 @@ database:
#PASSWORD: inventree_password
#HOST: 'localhost'
#PORT: '5432'
# --- Example Configuration - sqlite3 ---
# ENGINE: sqlite3
# NAME: '/home/inventree/database.sqlite3'
# Select default system language (default is 'en-us')
language: en-us

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ from decimal import Decimal
from django.db import models, transaction
from django.db.models import Q, F, Sum
from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
@ -24,6 +25,7 @@ from users import models as UserModels
from part import models as PartModels
from stock import models as stock_models
from company.models import Company, SupplierPart
from plugin.events import trigger_event
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
from InvenTree.helpers import decimal2string, increment, getSetting
@ -317,6 +319,8 @@ class PurchaseOrder(Order):
self.issue_date = datetime.now().date()
self.save()
trigger_event('purchaseorder.placed', id=self.pk)
@transaction.atomic
def complete_order(self):
""" Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """
@ -326,6 +330,8 @@ class PurchaseOrder(Order):
self.complete_date = datetime.now().date()
self.save()
trigger_event('purchaseorder.completed', id=self.pk)
@property
def is_overdue(self):
"""
@ -356,6 +362,8 @@ class PurchaseOrder(Order):
self.status = PurchaseOrderStatus.CANCELLED
self.save()
trigger_event('purchaseorder.cancelled', id=self.pk)
def pending_line_items(self):
""" Return a list of pending line items for this order.
Any line item where 'received' < 'quantity' will be returned.
@ -667,6 +675,8 @@ class SalesOrder(Order):
self.save()
trigger_event('salesorder.completed', id=self.pk)
return True
def can_cancel(self):
@ -698,6 +708,8 @@ class SalesOrder(Order):
for allocation in line.allocations.all():
allocation.delete()
trigger_event('salesorder.cancelled', id=self.pk)
return True
@property
@ -1104,6 +1116,8 @@ class SalesOrderShipment(models.Model):
self.save()
trigger_event('salesordershipment.completed', id=self.pk)
class SalesOrderAllocation(models.Model):
"""

View File

@ -1980,10 +1980,10 @@ class Part(MPTTModel):
@property
def attachment_count(self):
""" Count the number of attachments for this part.
"""
Count the number of attachments for this part.
If the part is a variant of a template part,
include the number of attachments for the template part.
"""
return self.part_attachments.count()
@ -2181,7 +2181,9 @@ def after_save_part(sender, instance: Part, created, **kwargs):
Function to be executed after a Part is saved
"""
if not created:
if created:
pass
else:
# Check part stock only if we are *updating* the part (not creating it)
# Run this check in the background

View File

@ -1,3 +1,7 @@
"""
Utility file to enable simper imports
"""
from .registry import plugin_registry
from .plugin import InvenTreePlugin
from .integration import IntegrationPluginBase

View File

@ -3,6 +3,8 @@ Plugin mixin classes
"""
import logging
import json
import requests
from django.conf.urls import url, include
from django.db.utils import OperationalError, ProgrammingError
@ -80,6 +82,7 @@ class ScheduleMixin:
ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y']
# Override this in subclass model
SCHEDULED_TASKS = {}
class MixinMeta:
@ -180,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
@ -373,3 +395,113 @@ class ActionMixin:
"result": self.get_result(),
"info": self.get_info(),
}
class APICallMixin:
"""
Mixin that enables easier API calls for a plugin
Steps to set up:
1. Add this mixin before (left of) SettingsMixin and PluginBase
2. Add two settings for the required url and token/passowrd (use `SettingsMixin`)
3. Save the references to keys of the settings in `API_URL_SETTING` and `API_TOKEN_SETTING`
4. (Optional) Set `API_TOKEN` to the name required for the token by the external API - Defaults to `Bearer`
5. (Optional) Override the `api_url` property method if the setting needs to be extended
6. (Optional) Override `api_headers` to add extra headers (by default the token and Content-Type are contained)
7. Access the API in you plugin code via `api_call`
Example:
```
from plugin import IntegrationPluginBase
from plugin.mixins import APICallMixin, SettingsMixin
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, IntegrationPluginBase):
'''
A small api call sample
'''
PLUGIN_NAME = "Sample API Caller"
SETTINGS = {
'API_TOKEN': {
'name': 'API Token',
'protected': True,
},
'API_URL': {
'name': 'External URL',
'description': 'Where is your API located?',
'default': 'reqres.in',
},
}
API_URL_SETTING = 'API_URL'
API_TOKEN_SETTING = 'API_TOKEN'
def get_external_url(self):
'''
returns data from the sample endpoint
'''
return self.api_call('api/users/2')
```
"""
API_METHOD = 'https'
API_URL_SETTING = None
API_TOKEN_SETTING = None
API_TOKEN = 'Bearer'
class MixinMeta:
"""meta options for this mixin"""
MIXIN_NAME = 'API calls'
def __init__(self):
super().__init__()
self.add_mixin('api_call', 'has_api_call', __class__)
@property
def has_api_call(self):
"""Is the mixin ready to call external APIs?"""
if not bool(self.API_URL_SETTING):
raise ValueError("API_URL_SETTING must be defined")
if not bool(self.API_TOKEN_SETTING):
raise ValueError("API_TOKEN_SETTING must be defined")
return True
@property
def api_url(self):
return f'{self.API_METHOD}://{self.get_setting(self.API_URL_SETTING)}'
@property
def api_headers(self):
return {
self.API_TOKEN: self.get_setting(self.API_TOKEN_SETTING),
'Content-Type': 'application/json'
}
def api_build_url_args(self, arguments):
groups = []
for key, val in arguments.items():
groups.append(f'{key}={",".join([str(a) for a in val])}')
return f'?{"&".join(groups)}'
def api_call(self, endpoint, method: str = 'GET', url_args=None, data=None, headers=None, simple_response: bool = True):
if url_args:
endpoint += self.api_build_url_args(url_args)
if headers is None:
headers = self.api_headers
# build kwargs for call
kwargs = {
'url': f'{self.api_url}/{endpoint}',
'headers': headers,
}
if data:
kwargs['data'] = json.dumps(data)
# run command
response = requests.request(method, **kwargs)
# return
if simple_response:
return response.json()
return response

177
InvenTree/plugin/events.py Normal file
View File

@ -0,0 +1,177 @@
"""
Functions for triggering and responding to server side events
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import logging
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_save, post_delete
from django.dispatch.dispatcher import receiver
from common.models import InvenTreeSetting
from InvenTree.ready import canAppAccessDatabase
from InvenTree.tasks import offload_task
from plugin.registry import plugin_registry
logger = logging.getLogger('inventree')
def trigger_event(event, *args, **kwargs):
"""
Trigger an event with optional arguments.
This event will be stored in the database,
and the worker will respond to it later on.
"""
if not canAppAccessDatabase():
logger.debug(f"Ignoring triggered event '{event}' - database not ready")
return
logger.debug(f"Event triggered: '{event}'")
offload_task(
'plugin.events.register_event',
event,
*args,
**kwargs
)
def register_event(event, *args, **kwargs):
"""
Register the event with any interested plugins.
Note: This function is processed by the background worker,
as it performs multiple database access operations.
"""
logger.debug(f"Registering triggered event: '{event}'")
# Determine if there are any plugins which are interested in responding
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'):
with transaction.atomic():
for slug, plugin in plugin_registry.plugins.items():
if plugin.mixin_enabled('events'):
config = plugin.plugin_config()
if config and config.active:
logger.debug(f"Registering callback for plugin '{slug}'")
# Offload a separate task for each plugin
offload_task(
'plugin.events.process_event',
slug,
event,
*args,
**kwargs
)
def process_event(plugin_slug, event, *args, **kwargs):
"""
Respond to a triggered event.
This function is run by the background worker process.
This function may queue multiple functions to be handled by the background worker.
"""
logger.info(f"Plugin '{plugin_slug}' is processing triggered event '{event}'")
plugin = plugin_registry.plugins[plugin_slug]
plugin.process_event(event, *args, **kwargs)
def allow_table_event(table_name):
"""
Determine if an automatic event should be fired for a given table.
We *do not* want events to be fired for some tables!
"""
table_name = table_name.lower().strip()
# Ignore any tables which start with these prefixes
ignore_prefixes = [
'account_',
'auth_',
'authtoken_',
'django_',
'error_',
'exchange_',
'otp_',
'plugin_',
'socialaccount_',
'user_',
'users_',
]
if any([table_name.startswith(prefix) for prefix in ignore_prefixes]):
return False
ignore_tables = [
'common_notificationentry',
]
if table_name in ignore_tables:
return False
return True
@receiver(post_save)
def after_save(sender, instance, created, **kwargs):
"""
Trigger an event whenever a database entry is saved
"""
table = sender.objects.model._meta.db_table
if not allow_table_event(table):
return
if created:
trigger_event(
'instance.created',
id=instance.id,
model=sender.__name__,
table=table,
)
else:
trigger_event(
'instance.saved',
id=instance.id,
model=sender.__name__,
table=table,
)
@receiver(post_delete)
def after_delete(sender, instance, **kwargs):
"""
Trigger an event whenever a database entry is deleted
"""
table = sender.objects.model._meta.db_table
if not allow_table_event(table):
return
trigger_event(
'instance.deleted',
model=sender.__name__,
table=table,
)

View File

@ -176,6 +176,11 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
"""check if mixin is enabled and ready"""
if self.mixin(key):
fnc_name = self._mixins.get(key)
# Allow for simple case where the mixin is "always" ready
if fnc_name is True:
return True
return getattr(self, fnc_name, True)
return False
# endregion

View File

@ -2,12 +2,14 @@
Utility class to enable simpler imports
"""
from ..builtin.integration.mixins import AppMixin, SettingsMixin, ScheduleMixin, UrlsMixin, NavigationMixin
from ..builtin.integration.mixins import APICallMixin, AppMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
from ..builtin.action.mixins import ActionMixin
from ..builtin.barcode.mixins import BarcodeMixin
__all__ = [
'APICallMixin',
'AppMixin',
'EventMixin',
'NavigationMixin',
'ScheduleMixin',
'SettingsMixin',

View File

@ -63,3 +63,15 @@ class InvenTreePlugin():
raise error
return cfg
def is_active(self):
"""
Return True if this plugin is currently active
"""
cfg = self.plugin_config()
if cfg:
return cfg.active
else:
return False

View File

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

View File

@ -0,0 +1,32 @@
"""
Sample plugin for calling an external API
"""
from plugin import IntegrationPluginBase
from plugin.mixins import APICallMixin, SettingsMixin
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, IntegrationPluginBase):
"""
A small api call sample
"""
PLUGIN_NAME = "Sample API Caller"
SETTINGS = {
'API_TOKEN': {
'name': 'API Token',
'protected': True,
},
'API_URL': {
'name': 'External URL',
'description': 'Where is your API located?',
'default': 'reqres.in',
},
}
API_URL_SETTING = 'API_URL'
API_TOKEN_SETTING = 'API_TOKEN'
def get_external_url(self):
"""
returns data from the sample endpoint
"""
return self.api_call('api/users/2')

View File

@ -0,0 +1,23 @@
"""
Sample plugin which responds to events
"""
from plugin import IntegrationPluginBase
from plugin.mixins import EventMixin
class EventPluginSample(EventMixin, IntegrationPluginBase):
"""
A sample plugin which provides supports for triggered events
"""
PLUGIN_NAME = "EventPlugin"
PLUGIN_SLUG = "event"
PLUGIN_TITLE = "Triggered Events"
def process_event(self, event, *args, **kwargs):
""" Custom event processing """
print(f"Processing triggered event: '{event}'")
print("args:", str(args))
print("kwargs:", str(kwargs))

View File

@ -15,10 +15,6 @@ def print_world():
print("World")
def fail_task():
raise ValueError("This task should fail!")
class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase):
"""
A sample plugin which provides support for scheduled tasks
@ -32,14 +28,10 @@ class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase):
'hello': {
'func': 'plugin.samples.integration.scheduled_task.print_hello',
'schedule': 'I',
'minutes': 5,
'minutes': 45,
},
'world': {
'func': 'plugin.samples.integration.scheduled_task.print_hello',
'schedule': 'H',
},
'failure': {
'func': 'plugin.samples.integration.scheduled_task.fail_task',
'schedule': 'D',
},
}

View File

@ -0,0 +1,21 @@
""" Unit tests for action caller sample"""
from django.test import TestCase
from plugin import plugin_registry
class SampleApiCallerPluginTests(TestCase):
""" Tests for SampleApiCallerPluginTests """
def test_return(self):
"""check if the external api call works"""
# The plugin should be defined
self.assertIn('sample-api-caller', plugin_registry.plugins)
plg = plugin_registry.plugins['sample-api-caller']
self.assertTrue(plg)
# do an api call
result = plg.get_external_url()
self.assertTrue(result)
self.assertIn('data', result,)

View File

@ -8,16 +8,16 @@ from django.contrib.auth import get_user_model
from datetime import datetime
from plugin import IntegrationPluginBase
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
from plugin.urls import PLUGIN_BASE
class BaseMixinDefinition:
def test_mixin_name(self):
# mixin name
self.assertEqual(self.mixin.registered_mixins[0]['key'], self.MIXIN_NAME)
self.assertIn(self.MIXIN_NAME, [item['key'] for item in self.mixin.registered_mixins])
# human name
self.assertEqual(self.mixin.registered_mixins[0]['human_name'], self.MIXIN_HUMAN_NAME)
self.assertIn(self.MIXIN_HUMAN_NAME, [item['human_name'] for item in self.mixin.registered_mixins])
class SettingsMixinTest(BaseMixinDefinition, TestCase):
@ -142,6 +142,79 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
self.assertEqual(self.nothing_mixin.navigation_name, '')
class APICallMixinTest(BaseMixinDefinition, TestCase):
MIXIN_HUMAN_NAME = 'API calls'
MIXIN_NAME = 'api_call'
MIXIN_ENABLE_CHECK = 'has_api_call'
def setUp(self):
class MixinCls(APICallMixin, SettingsMixin, IntegrationPluginBase):
PLUGIN_NAME = "Sample API Caller"
SETTINGS = {
'API_TOKEN': {
'name': 'API Token',
'protected': True,
},
'API_URL': {
'name': 'External URL',
'description': 'Where is your API located?',
'default': 'reqres.in',
},
}
API_URL_SETTING = 'API_URL'
API_TOKEN_SETTING = 'API_TOKEN'
def get_external_url(self):
'''
returns data from the sample endpoint
'''
return self.api_call('api/users/2')
self.mixin = MixinCls()
class WrongCLS(APICallMixin, IntegrationPluginBase):
pass
self.mixin_wrong = WrongCLS()
class WrongCLS2(APICallMixin, IntegrationPluginBase):
API_URL_SETTING = 'test'
self.mixin_wrong2 = WrongCLS2()
def test_function(self):
# check init
self.assertTrue(self.mixin.has_api_call)
# api_url
self.assertEqual('https://reqres.in', self.mixin.api_url)
# api_headers
headers = self.mixin.api_headers
self.assertEqual(headers, {'Bearer': '', 'Content-Type': 'application/json'})
# api_build_url_args
# 1 arg
result = self.mixin.api_build_url_args({'a': 'b'})
self.assertEqual(result, '?a=b')
# more args
result = self.mixin.api_build_url_args({'a': 'b', 'c': 'd'})
self.assertEqual(result, '?a=b&c=d')
# list args
result = self.mixin.api_build_url_args({'a': 'b', 'c': ['d', 'e', 'f', ]})
self.assertEqual(result, '?a=b&c=d,e,f')
# api_call
result = self.mixin.get_external_url()
self.assertTrue(result)
self.assertIn('data', result,)
# wrongly defined plugins should not load
with self.assertRaises(ValueError):
self.mixin_wrong.has_api_call()
# cover wrong token setting
with self.assertRaises(ValueError):
self.mixin_wrong.has_api_call()
class IntegrationPluginBaseTests(TestCase):
""" Tests for IntegrationPluginBase """

View File

@ -35,6 +35,8 @@ import common.models
import report.models
import label.models
from plugin.events import trigger_event
from InvenTree.status_codes import StockStatus, StockHistoryCode
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
@ -718,6 +720,12 @@ class StockItem(MPTTModel):
notes=notes,
)
trigger_event(
'stockitem.assignedtocustomer',
id=self.id,
customer=customer.id,
)
# Return the reference to the stock item
return item
@ -745,6 +753,11 @@ class StockItem(MPTTModel):
self.customer = None
self.location = location
trigger_event(
'stockitem.returnedfromcustomer',
id=self.id,
)
self.save()
# If stock item is incoming, an (optional) ETA field
@ -1786,7 +1799,7 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs):
@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log')
def after_save_stock_item(sender, instance: StockItem, **kwargs):
def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
"""
Hook function to be executed after StockItem object is saved/updated
"""

View File

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

View File

@ -20,6 +20,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_SCHEDULE" icon="fa-calendar-alt" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_EVENTS" icon="fa-reply-all" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}

View File

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

View File

@ -12,7 +12,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="file-pdf" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="fa-file-pdf" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %}

View File

@ -11,7 +11,6 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="STOCK_GROUP_BY_PART" icon="fa-layer-group" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}

View File

@ -214,88 +214,6 @@
</div>
</div>
<div class='row'>
<div class='panel-heading'>
<h4>{% trans "Theme Settings" %}</h4>
</div>
<div class='col-sm-6'>
<form action='{% url "settings-appearance" %}' method='post'>
{% csrf_token %}
<input name='next' type='hidden' value='{% url "settings" %}'>
<label for='theme' class=' requiredField'>
{% trans "Select theme" %}
</label>
<div class='form-group input-group mb-3'>
<select id='theme' name='theme' class='select form-control'>
{% get_available_themes as themes %}
{% for theme in themes %}
<option value='{{ theme.key }}'>{{ theme.name }}</option>
{% endfor %}
</select>
<div class='input-group-append'>
<input type="submit" value="{% trans 'Set Theme' %}" class="btn btn-primary">
</div>
</div>
</form>
</div>
</div>
<div class="row">
<div class='panel-heading'>
<h4>{% trans "Language Settings" %}</h4>
</div>
<div class="col">
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<input name="next" type="hidden" value="{% url 'settings' %}">
<label for='language' class=' requiredField'>
{% trans "Select language" %}
</label>
<div class='form-group input-group mb-3'>
<select name="language" class="select form-control w-25">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% if 'alllang' in request.GET %}{% define True as ALL_LANG %}{% endif %}
{% for language in languages %}
{% define language.code as lang_code %}
{% define locale_stats|keyvalue:lang_code as lang_translated %}
{% if lang_translated > 10 or lang_code == 'en' or lang_code == LANGUAGE_CODE %}{% define True as use_lang %}{% else %}{% define False as use_lang %}{% endif %}
{% if ALL_LANG or use_lang %}
<option value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ lang_code }})
{% if lang_translated %}
{% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %}
{% else %}
{% if lang_code == 'en' %}-{% else %}{% trans 'No translations available' %}{% endif %}
{% endif %}
</option>
{% endif %}
{% endfor %}
</select>
<div class='input-group-append'>
<input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary">
</div>
<p>{% trans "Some languages are not complete" %}
{% if ALL_LANG %}
. <a href="{% url 'settings' %}">{% trans "Show only sufficent" %}</a>
{% else %}
and hidden. <a href="?alllang">{% trans "Show them too" %}</a>
{% endif %}
</p>
</div>
</form>
</div>
<div class="col-sm-6">
<h4>{% trans "Help the translation efforts!" %}</h4>
<p>{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the
InvenTree web application is <a href="{{link}}">community contributed via crowdin</a>. Contributions are
welcomed and encouraged.{% endblocktrans %}</p>
</div>
</div>
<div class="row">
<div class='panel-heading'>
<div class='d-flex flex-wrap'>

View File

@ -6,6 +6,7 @@
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
{% settings_value "REPORT_ENABLE" as report_enabled %}
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
{% inventree_demo_mode as demo_mode %}
<!DOCTYPE html>
<html lang="en">
@ -90,7 +91,7 @@
{% block alerts %}
<div class='notification-area' id='alerts'>
<!-- Div for displayed alerts -->
{% if server_restart_required %}
{% if server_restart_required and not demo_mode %}
<div id='alert-restart-server' class='alert alert-danger' role='alert'>
<span class='fas fa-server'></span>
<b>{% trans "Server Restart Required" %}</b>

View File

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

View File

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

View File

@ -108,7 +108,7 @@
<ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'>
{% if user.is_authenticated %}
{% if user.is_staff and not demo %}
<li><a class='dropdown-item' href="/admin/"><span class="fas fa-user"></span> {% trans "Admin" %}</a></li>
<li><a class='dropdown-item' href="/admin/"><span class="fas fa-user-shield"></span> {% trans "Admin" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href="{% url 'account_logout' %}"><span class="fas fa-sign-out-alt"></span> {% trans "Logout" %}</a></li>
{% else %}