mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue2279
This commit is contained in:
commit
ee99052d04
3
.github/workflows/qc_checks.yaml
vendored
3
.github/workflows/qc_checks.yaml
vendored
@ -156,6 +156,7 @@ jobs:
|
||||
env:
|
||||
INVENTREE_DB_NAME: ./inventree.sqlite
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
@ -204,6 +205,7 @@ jobs:
|
||||
INVENTREE_DB_PORT: 5432
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_CACHE_HOST: localhost
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@ -259,6 +261,7 @@ jobs:
|
||||
INVENTREE_DB_HOST: '127.0.0.1'
|
||||
INVENTREE_DB_PORT: 3306
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
|
||||
services:
|
||||
mysql:
|
||||
|
25
.github/workflows/stale.yml
vendored
Normal file
25
.github/workflows/stale.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# Marks all issues that do not receive activity stale starting 2022
|
||||
name: Mark stale issues and pull requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '24 11 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue seems stale. Please react to show this is still important.'
|
||||
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
|
||||
stale-issue-label: 'no-activity'
|
||||
stale-pr-label: 'no-activity'
|
||||
start-date: '2022-01-01'
|
||||
exempt-all-milestones: true
|
@ -2,9 +2,14 @@
|
||||
Custom management command to cleanup old settings that are not defined anymore
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Cleanup old (undefined) settings in the database
|
||||
@ -12,27 +17,27 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
|
||||
print("Collecting settings")
|
||||
logger.info("Collecting settings")
|
||||
from common.models import InvenTreeSetting, InvenTreeUserSetting
|
||||
|
||||
# general settings
|
||||
db_settings = InvenTreeSetting.objects.all()
|
||||
model_settings = InvenTreeSetting.GLOBAL_SETTINGS
|
||||
model_settings = InvenTreeSetting.SETTINGS
|
||||
|
||||
# check if key exist and delete if not
|
||||
for setting in db_settings:
|
||||
if setting.key not in model_settings:
|
||||
setting.delete()
|
||||
print(f"deleted setting '{setting.key}'")
|
||||
logger.info(f"deleted setting '{setting.key}'")
|
||||
|
||||
# user settings
|
||||
db_settings = InvenTreeUserSetting.objects.all()
|
||||
model_settings = InvenTreeUserSetting.GLOBAL_SETTINGS
|
||||
model_settings = InvenTreeUserSetting.SETTINGS
|
||||
|
||||
# check if key exist and delete if not
|
||||
for setting in db_settings:
|
||||
if setting.key not in model_settings:
|
||||
setting.delete()
|
||||
print(f"deleted user setting '{setting.key}'")
|
||||
logger.info(f"deleted user setting '{setting.key}'")
|
||||
|
||||
print("checked all settings")
|
||||
logger.info("checked all settings")
|
||||
|
@ -52,8 +52,6 @@ with open(cfg_filename, 'r') as cfg:
|
||||
# We will place any config files in the same directory as the config file
|
||||
config_dir = os.path.dirname(cfg_filename)
|
||||
|
||||
PLUGIN_FILE = get_plugin_file()
|
||||
|
||||
# Default action is to run the system in Debug mode
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = _is_true(get_setting(
|
||||
@ -873,6 +871,14 @@ MARKDOWNIFY_BLEACH = False
|
||||
# Maintenance mode
|
||||
MAINTENANCE_MODE_RETRY_AFTER = 60
|
||||
|
||||
# Are plugins enabled?
|
||||
PLUGINS_ENABLED = _is_true(get_setting(
|
||||
'INVENTREE_PLUGINS_ENABLED',
|
||||
CONFIG.get('plugins_enabled', False),
|
||||
))
|
||||
|
||||
PLUGIN_FILE = get_plugin_file()
|
||||
|
||||
# Plugin Directories (local plugins will be loaded from these directories)
|
||||
PLUGIN_DIRS = ['plugin.builtin', ]
|
||||
|
||||
|
@ -319,6 +319,7 @@ main {
|
||||
display: inline-block;
|
||||
*display: inline;
|
||||
zoom: 1;
|
||||
padding-top: 3px;
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
border: 1px solid #aaa;
|
||||
@ -327,6 +328,7 @@ main {
|
||||
margin: 1px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
|
@ -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():
|
||||
|
@ -53,7 +53,14 @@ from users.api import user_urls
|
||||
|
||||
admin.site.site_header = "InvenTree Admin"
|
||||
|
||||
apipatterns = [
|
||||
apipatterns = []
|
||||
|
||||
if settings.PLUGINS_ENABLED:
|
||||
apipatterns.append(
|
||||
url(r'^plugin/', include(plugin_api_urls))
|
||||
)
|
||||
|
||||
apipatterns += [
|
||||
url(r'^barcode/', include(barcode_api_urls)),
|
||||
url(r'^settings/', include(settings_api_urls)),
|
||||
url(r'^part/', include(part_api_urls)),
|
||||
@ -64,7 +71,6 @@ apipatterns = [
|
||||
url(r'^order/', include(order_api_urls)),
|
||||
url(r'^label/', include(label_api_urls)),
|
||||
url(r'^report/', include(report_api_urls)),
|
||||
url(r'^plugin/', include(plugin_api_urls)),
|
||||
|
||||
# User URLs
|
||||
url(r'^user/', include(user_urls)),
|
||||
@ -72,8 +78,8 @@ apipatterns = [
|
||||
# Plugin endpoints
|
||||
url(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
|
||||
|
||||
# common endpoints
|
||||
url(r'', include(common_api_urls)),
|
||||
# Webhook enpoint
|
||||
path('', include(common_api_urls)),
|
||||
|
||||
# InvenTree information endpoint
|
||||
url(r'^$', InfoView.as_view(), name='api-inventree-info'),
|
||||
@ -171,9 +177,6 @@ frontendpatterns = [
|
||||
url(r'^search/', SearchView.as_view(), name='search'),
|
||||
url(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
|
||||
|
||||
# plugin urls
|
||||
get_plugin_urls(), # appends currently loaded plugin urls = None
|
||||
|
||||
# admin sites
|
||||
url(r'^admin/error_log/', include('error_report.urls')),
|
||||
url(r'^admin/shell/', include('django_admin_shell.urls')),
|
||||
@ -192,6 +195,10 @@ frontendpatterns = [
|
||||
url(r'^accounts/', include('allauth.urls')), # included urlpatterns
|
||||
]
|
||||
|
||||
# Append custom plugin URLs (if plugin support is enabled)
|
||||
if settings.PLUGINS_ENABLED:
|
||||
frontendpatterns.append(get_plugin_urls())
|
||||
|
||||
urlpatterns = [
|
||||
url('', include(frontendpatterns)),
|
||||
url('', include(backendpatterns)),
|
||||
|
@ -12,11 +12,15 @@ import common.models
|
||||
INVENTREE_SW_VERSION = "0.6.0 dev"
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 22
|
||||
INVENTREE_API_VERSION = 23
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v23 -> 2022-02-02
|
||||
- Adds API endpoints for managing plugin classes
|
||||
- Adds API endpoints for managing plugin settings
|
||||
|
||||
v22 -> 2021-12-20
|
||||
- Adds API endpoint to "merge" multiple stock items
|
||||
|
||||
|
@ -18,8 +18,7 @@ from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
|
||||
from .models import Build, BuildItem, BuildOrderAttachment
|
||||
from .serializers import BuildAttachmentSerializer, BuildCompleteSerializer, BuildSerializer, BuildItemSerializer
|
||||
from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
|
||||
import build.serializers
|
||||
from users.models import Owner
|
||||
|
||||
|
||||
@ -80,7 +79,7 @@ class BuildList(generics.ListCreateAPIView):
|
||||
"""
|
||||
|
||||
queryset = Build.objects.all()
|
||||
serializer_class = BuildSerializer
|
||||
serializer_class = build.serializers.BuildSerializer
|
||||
filterset_class = BuildFilter
|
||||
|
||||
filter_backends = [
|
||||
@ -119,7 +118,7 @@ class BuildList(generics.ListCreateAPIView):
|
||||
|
||||
queryset = super().get_queryset().select_related('part')
|
||||
|
||||
queryset = BuildSerializer.annotate_queryset(queryset)
|
||||
queryset = build.serializers.BuildSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -203,7 +202,7 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
|
||||
""" API endpoint for detail view of a Build object """
|
||||
|
||||
queryset = Build.objects.all()
|
||||
serializer_class = BuildSerializer
|
||||
serializer_class = build.serializers.BuildSerializer
|
||||
|
||||
|
||||
class BuildUnallocate(generics.CreateAPIView):
|
||||
@ -217,7 +216,7 @@ class BuildUnallocate(generics.CreateAPIView):
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = BuildUnallocationSerializer
|
||||
serializer_class = build.serializers.BuildUnallocationSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
@ -233,14 +232,36 @@ class BuildUnallocate(generics.CreateAPIView):
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildComplete(generics.CreateAPIView):
|
||||
class BuildOutputComplete(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for completing build outputs
|
||||
"""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = BuildCompleteSerializer
|
||||
serializer_class = build.serializers.BuildOutputCompleteSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
ctx['request'] = self.request
|
||||
|
||||
try:
|
||||
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||
except:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildFinish(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for marking a build as finished (completed)
|
||||
"""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = build.serializers.BuildCompleteSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
@ -269,7 +290,7 @@ class BuildAllocate(generics.CreateAPIView):
|
||||
|
||||
queryset = Build.objects.none()
|
||||
|
||||
serializer_class = BuildAllocationSerializer
|
||||
serializer_class = build.serializers.BuildAllocationSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""
|
||||
@ -294,7 +315,7 @@ class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
|
||||
queryset = BuildItem.objects.all()
|
||||
serializer_class = BuildItemSerializer
|
||||
serializer_class = build.serializers.BuildItemSerializer
|
||||
|
||||
|
||||
class BuildItemList(generics.ListCreateAPIView):
|
||||
@ -304,7 +325,7 @@ class BuildItemList(generics.ListCreateAPIView):
|
||||
- POST: Create a new BuildItem object
|
||||
"""
|
||||
|
||||
serializer_class = BuildItemSerializer
|
||||
serializer_class = build.serializers.BuildItemSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
@ -373,7 +394,7 @@ class BuildAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
"""
|
||||
|
||||
queryset = BuildOrderAttachment.objects.all()
|
||||
serializer_class = BuildAttachmentSerializer
|
||||
serializer_class = build.serializers.BuildAttachmentSerializer
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
@ -390,7 +411,7 @@ class BuildAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix
|
||||
"""
|
||||
|
||||
queryset = BuildOrderAttachment.objects.all()
|
||||
serializer_class = BuildAttachmentSerializer
|
||||
serializer_class = build.serializers.BuildAttachmentSerializer
|
||||
|
||||
|
||||
build_api_urls = [
|
||||
@ -410,7 +431,8 @@ build_api_urls = [
|
||||
# Build Detail
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||
url(r'^complete/', BuildComplete.as_view(), name='api-build-complete'),
|
||||
url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
|
||||
url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
|
||||
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||
])),
|
||||
|
@ -83,24 +83,6 @@ class BuildOutputDeleteForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class CompleteBuildForm(HelperForm):
|
||||
"""
|
||||
Form for marking a build as complete
|
||||
"""
|
||||
|
||||
confirm = forms.BooleanField(
|
||||
required=True,
|
||||
label=_('Confirm'),
|
||||
help_text=_('Mark build as complete'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Build
|
||||
fields = [
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
class CancelBuildForm(HelperForm):
|
||||
""" Form for cancelling a build """
|
||||
|
||||
|
@ -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
|
||||
@ -555,7 +557,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
if self.incomplete_count > 0:
|
||||
return False
|
||||
|
||||
if self.completed < self.quantity:
|
||||
if self.remaining > 0:
|
||||
return False
|
||||
|
||||
if not self.areUntrackedPartsFullyAllocated():
|
||||
@ -582,9 +584,12 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
self.subtractUntrackedStock(user)
|
||||
|
||||
# Ensure that there are no longer any BuildItem objects
|
||||
# which point to thie Build Order
|
||||
# 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):
|
||||
"""
|
||||
|
@ -165,7 +165,7 @@ class BuildOutputSerializer(serializers.Serializer):
|
||||
]
|
||||
|
||||
|
||||
class BuildCompleteSerializer(serializers.Serializer):
|
||||
class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for completing one or more build outputs
|
||||
"""
|
||||
@ -240,6 +240,60 @@ class BuildCompleteSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class BuildCompleteSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for marking a BuildOrder as complete
|
||||
"""
|
||||
|
||||
accept_unallocated = serializers.BooleanField(
|
||||
label=_('Accept Unallocated'),
|
||||
help_text=_('Accept that stock items have not been fully allocated to this build order'),
|
||||
required=False,
|
||||
default=False,
|
||||
)
|
||||
|
||||
def validate_accept_unallocated(self, value):
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
if not build.areUntrackedPartsFullyAllocated() and not value:
|
||||
raise ValidationError(_('Required stock has not been fully allocated'))
|
||||
|
||||
return value
|
||||
|
||||
accept_incomplete = serializers.BooleanField(
|
||||
label=_('Accept Incomplete'),
|
||||
help_text=_('Accept that the required number of build outputs have not been completed'),
|
||||
required=False,
|
||||
default=False,
|
||||
)
|
||||
|
||||
def validate_accept_incomplete(self, value):
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
if build.remaining > 0 and not value:
|
||||
raise ValidationError(_('Required build quantity has not been completed'))
|
||||
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
build = self.context['build']
|
||||
|
||||
if build.incomplete_count > 0:
|
||||
raise ValidationError(_("Build order has incomplete outputs"))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
|
||||
request = self.context['request']
|
||||
build = self.context['build']
|
||||
|
||||
build.complete_build(request.user)
|
||||
|
||||
|
||||
class BuildUnallocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
DRF serializer for unallocating stock from a BuildOrder
|
||||
|
@ -221,16 +221,17 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% if build.incomplete_count > 0 %}
|
||||
showAlertDialog(
|
||||
'{% trans "Incomplete Outputs" %}',
|
||||
'{% trans "Build Order cannot be completed as incomplete build outputs remain" %}'
|
||||
);
|
||||
{% else %}
|
||||
launchModalForm(
|
||||
"{% url 'build-complete' build.id %}",
|
||||
'{% trans "Build Order cannot be completed as incomplete build outputs remain" %}',
|
||||
{
|
||||
reload: true,
|
||||
submit_text: '{% trans "Complete Build" %}',
|
||||
alert_style: 'danger',
|
||||
}
|
||||
);
|
||||
{% else %}
|
||||
|
||||
completeBuildOrder({{ build.pk }}, {
|
||||
allocated: {% if build.areUntrackedPartsFullyAllocated %}true{% else %}false{% endif %},
|
||||
completed: {% if build.remaining == 0 %}true{% else %}false{% endif %},
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
|
@ -1,26 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
{% if build.can_complete %}
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Build Order is complete" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
<strong>{% trans "Build Order is incomplete" %}</strong><br>
|
||||
<ul>
|
||||
{% if build.incomplete_count > 0 %}
|
||||
<li>{% trans "Incompleted build outputs remain" %}</li>
|
||||
{% endif %}
|
||||
{% if build.completed < build.quantity %}
|
||||
<li>{% trans "Required build quantity has not been completed" %}</li>
|
||||
{% endif %}
|
||||
{% if not build.areUntrackedPartsFullyAllocated %}
|
||||
<li>{% trans "Required stock has not been fully allocated" %}</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@ -38,7 +38,7 @@ class BuildAPITest(InvenTreeAPITestCase):
|
||||
super().setUp()
|
||||
|
||||
|
||||
class BuildCompleteTest(BuildAPITest):
|
||||
class BuildOutputCompleteTest(BuildAPITest):
|
||||
"""
|
||||
Unit testing for the build complete API endpoint
|
||||
"""
|
||||
@ -49,7 +49,7 @@ class BuildCompleteTest(BuildAPITest):
|
||||
|
||||
self.build = Build.objects.get(pk=1)
|
||||
|
||||
self.url = reverse('api-build-complete', kwargs={'pk': self.build.pk})
|
||||
self.url = reverse('api-build-output-complete', kwargs={'pk': self.build.pk})
|
||||
|
||||
def test_invalid(self):
|
||||
"""
|
||||
@ -58,7 +58,7 @@ class BuildCompleteTest(BuildAPITest):
|
||||
|
||||
# Test with an invalid build ID
|
||||
self.post(
|
||||
reverse('api-build-complete', kwargs={'pk': 99999}),
|
||||
reverse('api-build-output-complete', kwargs={'pk': 99999}),
|
||||
{},
|
||||
expected_code=400
|
||||
)
|
||||
@ -140,6 +140,9 @@ class BuildCompleteTest(BuildAPITest):
|
||||
Test build order completion
|
||||
"""
|
||||
|
||||
# Initially, build should not be able to be completed
|
||||
self.assertFalse(self.build.can_complete)
|
||||
|
||||
# We start without any outputs assigned against the build
|
||||
self.assertEqual(self.build.incomplete_outputs.count(), 0)
|
||||
|
||||
@ -153,7 +156,7 @@ class BuildCompleteTest(BuildAPITest):
|
||||
self.assertEqual(self.build.completed, 0)
|
||||
|
||||
# We shall complete 4 of these outputs
|
||||
outputs = self.build.incomplete_outputs[0:4]
|
||||
outputs = self.build.incomplete_outputs.all()
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
@ -165,19 +168,43 @@ class BuildCompleteTest(BuildAPITest):
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
# There now should be 6 incomplete build outputs remaining
|
||||
self.assertEqual(self.build.incomplete_outputs.count(), 6)
|
||||
self.assertEqual(self.build.incomplete_outputs.count(), 0)
|
||||
|
||||
# And there should be 4 completed outputs
|
||||
# And there should be 10 completed outputs
|
||||
outputs = self.build.complete_outputs
|
||||
self.assertEqual(outputs.count(), 4)
|
||||
self.assertEqual(outputs.count(), 10)
|
||||
|
||||
for output in outputs:
|
||||
self.assertFalse(output.is_building)
|
||||
self.assertEqual(output.build, self.build)
|
||||
|
||||
self.build.refresh_from_db()
|
||||
self.assertEqual(self.build.completed, 40)
|
||||
self.assertEqual(self.build.completed, 100)
|
||||
|
||||
# Try to complete the build (it should fail)
|
||||
finish_url = reverse('api-build-finish', kwargs={'pk': self.build.pk})
|
||||
|
||||
response = self.post(
|
||||
finish_url,
|
||||
{},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertTrue('accept_unallocated' in response.data)
|
||||
|
||||
# Accept unallocated stock
|
||||
response = self.post(
|
||||
finish_url,
|
||||
{
|
||||
'accept_unallocated': True,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.build.refresh_from_db()
|
||||
|
||||
# Build should have been marked as complete
|
||||
self.assertTrue(self.build.is_complete)
|
||||
|
||||
|
||||
class BuildAllocationTest(BuildAPITest):
|
||||
|
@ -11,7 +11,6 @@ build_detail_urls = [
|
||||
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
||||
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
||||
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
|
||||
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
|
||||
|
||||
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||
]
|
||||
|
@ -246,39 +246,6 @@ class BuildOutputDelete(AjaxUpdateView):
|
||||
}
|
||||
|
||||
|
||||
class BuildComplete(AjaxUpdateView):
|
||||
"""
|
||||
View to mark the build as complete.
|
||||
|
||||
Requirements:
|
||||
- There can be no outstanding build outputs
|
||||
- The "completed" value must meet or exceed the "quantity" value
|
||||
"""
|
||||
|
||||
model = Build
|
||||
form_class = forms.CompleteBuildForm
|
||||
|
||||
ajax_form_title = _('Complete Build Order')
|
||||
ajax_template_name = 'build/complete.html'
|
||||
|
||||
def validate(self, build, form, **kwargs):
|
||||
|
||||
if build.incomplete_count > 0:
|
||||
form.add_error(None, _('Build order cannot be completed - incomplete outputs remain'))
|
||||
|
||||
def save(self, build, form, **kwargs):
|
||||
"""
|
||||
Perform the build completion step
|
||||
"""
|
||||
|
||||
build.complete_build(self.request.user)
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': _('Completed build order')
|
||||
}
|
||||
|
||||
|
||||
class BuildDetail(InvenTreeRoleMixin, DetailView):
|
||||
"""
|
||||
Detail view of a single Build object.
|
||||
|
@ -38,6 +38,11 @@ class UserSettingsAdmin(ImportExportModelAdmin):
|
||||
return []
|
||||
|
||||
|
||||
class WebhookAdmin(ImportExportModelAdmin):
|
||||
|
||||
list_display = ('endpoint_id', 'name', 'active', 'user')
|
||||
|
||||
|
||||
class NotificationEntryAdmin(admin.ModelAdmin):
|
||||
|
||||
list_display = ('key', 'uid', 'updated', )
|
||||
@ -54,5 +59,7 @@ class NotificationMessageAdmin(admin.ModelAdmin):
|
||||
|
||||
admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
|
||||
admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
|
||||
admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
|
||||
admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
|
||||
admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)
|
||||
admin.site.register(common.models.NotificationMessage, NotificationMessageAdmin)
|
||||
|
@ -5,14 +5,102 @@ Provides a JSON API for common components.
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
|
||||
from django.http.response import HttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.conf.urls import url, include
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.exceptions import NotAcceptable, NotFound
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters, generics, permissions
|
||||
from rest_framework import serializers
|
||||
from django_q.tasks import async_task
|
||||
|
||||
import common.models
|
||||
import common.serializers
|
||||
from InvenTree.helpers import inheritors
|
||||
|
||||
|
||||
class CsrfExemptMixin(object):
|
||||
"""
|
||||
Exempts the view from CSRF requirements.
|
||||
"""
|
||||
|
||||
@method_decorator(csrf_exempt)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
return super(CsrfExemptMixin, self).dispatch(*args, **kwargs)
|
||||
|
||||
|
||||
class WebhookView(CsrfExemptMixin, APIView):
|
||||
"""
|
||||
Endpoint for receiving webhooks.
|
||||
"""
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
model_class = common.models.WebhookEndpoint
|
||||
run_async = False
|
||||
|
||||
def post(self, request, endpoint, *args, **kwargs):
|
||||
# get webhook definition
|
||||
self._get_webhook(endpoint, request, *args, **kwargs)
|
||||
|
||||
# check headers
|
||||
headers = request.headers
|
||||
try:
|
||||
payload = json.loads(request.body)
|
||||
except json.decoder.JSONDecodeError as error:
|
||||
raise NotAcceptable(error.msg)
|
||||
|
||||
# validate
|
||||
self.webhook.validate_token(payload, headers, request)
|
||||
# process data
|
||||
message = self.webhook.save_data(payload, headers, request)
|
||||
if self.run_async:
|
||||
async_task(self._process_payload, message.id)
|
||||
else:
|
||||
self._process_result(
|
||||
self.webhook.process_payload(message, payload, headers),
|
||||
message,
|
||||
)
|
||||
|
||||
# return results
|
||||
data = self.webhook.get_return(payload, headers, request)
|
||||
return HttpResponse(data)
|
||||
|
||||
def _process_payload(self, message_id):
|
||||
message = common.models.WebhookMessage.objects.get(message_id=message_id)
|
||||
self._process_result(
|
||||
self.webhook.process_payload(message, message.body, message.header),
|
||||
message,
|
||||
)
|
||||
|
||||
def _process_result(self, result, message):
|
||||
if result:
|
||||
message.worked_on = result
|
||||
message.save()
|
||||
else:
|
||||
message.delete()
|
||||
|
||||
def _escalate_object(self, obj):
|
||||
classes = inheritors(obj.__class__)
|
||||
for cls in classes:
|
||||
mdl_name = cls._meta.model_name
|
||||
if hasattr(obj, mdl_name):
|
||||
return getattr(obj, mdl_name)
|
||||
return obj
|
||||
|
||||
def _get_webhook(self, endpoint, request, *args, **kwargs):
|
||||
try:
|
||||
webhook = self.model_class.objects.get(endpoint_id=endpoint)
|
||||
self.webhook = self._escalate_object(webhook)
|
||||
self.webhook.init(request, *args, **kwargs)
|
||||
return self.webhook.process_webhook()
|
||||
except self.model_class.DoesNotExist:
|
||||
raise NotFound()
|
||||
|
||||
|
||||
class SettingsList(generics.ListAPIView):
|
||||
@ -181,7 +269,6 @@ class NotificationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
queryset = common.models.NotificationMessage.objects.all()
|
||||
serializer_class = common.serializers.NotificationMessageSerializer
|
||||
|
||||
permission_classes = [
|
||||
UserSettingsPermissions,
|
||||
]
|
||||
@ -249,6 +336,8 @@ settings_api_urls = [
|
||||
]
|
||||
|
||||
common_api_urls = [
|
||||
# Webhooks
|
||||
path('webhook/<slug:endpoint>/', WebhookView.as_view(), name='api-webhook'),
|
||||
|
||||
# Notifications
|
||||
url(r'^notifications/', include([
|
||||
|
@ -0,0 +1,40 @@
|
||||
# Generated by Django 3.2.5 on 2021-11-19 21:34
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('common', '0012_notificationentry'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WebhookEndpoint',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('endpoint_id', models.CharField(default=uuid.uuid4, editable=False, help_text='Endpoint at which this webhook is received', max_length=255, verbose_name='Endpoint')),
|
||||
('name', models.CharField(blank=True, help_text='Name for this webhook', max_length=255, null=True, verbose_name='Name')),
|
||||
('active', models.BooleanField(default=True, help_text='Is this webhook active', verbose_name='Active')),
|
||||
('token', models.CharField(blank=True, default=uuid.uuid4, help_text='Token for access', max_length=255, null=True, verbose_name='Token')),
|
||||
('secret', models.CharField(blank=True, help_text='Shared secret for HMAC', max_length=255, null=True, verbose_name='Secret')),
|
||||
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WebhookMessage',
|
||||
fields=[
|
||||
('message_id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unique identifier for this message', primary_key=True, serialize=False, verbose_name='Message ID')),
|
||||
('host', models.CharField(editable=False, help_text='Host from which this message was received', max_length=255, verbose_name='Host')),
|
||||
('header', models.CharField(blank=True, editable=False, help_text='Header of this message', max_length=255, null=True, verbose_name='Header')),
|
||||
('body', models.JSONField(blank=True, editable=False, help_text='Body of this message', null=True, verbose_name='Body')),
|
||||
('worked_on', models.BooleanField(default=False, help_text='Was the work on this message finished?', verbose_name='Worked on')),
|
||||
('endpoint', models.ForeignKey(blank=True, help_text='Endpoint on which this message was received', null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.webhookendpoint', verbose_name='Endpoint')),
|
||||
],
|
||||
),
|
||||
]
|
@ -9,6 +9,12 @@ from __future__ import unicode_literals
|
||||
import os
|
||||
import decimal
|
||||
import math
|
||||
import uuid
|
||||
import hmac
|
||||
import json
|
||||
import hashlib
|
||||
import base64
|
||||
from secrets import compare_digest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.db import models, transaction
|
||||
@ -25,6 +31,8 @@ from djmoney.settings import CURRENCY_CHOICES
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.validators import MinValueValidator, URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
@ -58,7 +66,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
single values (e.g. one-off settings values).
|
||||
"""
|
||||
|
||||
GLOBAL_SETTINGS = {}
|
||||
SETTINGS = {}
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -70,7 +78,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
self.key = str(self.key).upper()
|
||||
|
||||
self.clean()
|
||||
self.clean(**kwargs)
|
||||
self.validate_unique()
|
||||
|
||||
super().save()
|
||||
@ -87,6 +95,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
results = cls.objects.all()
|
||||
|
||||
# Optionally filter by user
|
||||
if user is not None:
|
||||
results = results.filter(user=user)
|
||||
|
||||
@ -98,13 +107,13 @@ class BaseInvenTreeSetting(models.Model):
|
||||
settings[setting.key.upper()] = setting.value
|
||||
|
||||
# Specify any "default" values which are not in the database
|
||||
for key in cls.GLOBAL_SETTINGS.keys():
|
||||
for key in cls.SETTINGS.keys():
|
||||
|
||||
if key.upper() not in settings:
|
||||
settings[key.upper()] = cls.get_setting_default(key)
|
||||
|
||||
if exclude_hidden:
|
||||
hidden = cls.GLOBAL_SETTINGS[key].get('hidden', False)
|
||||
hidden = cls.SETTINGS[key].get('hidden', False)
|
||||
|
||||
if hidden:
|
||||
# Remove hidden items
|
||||
@ -128,98 +137,92 @@ class BaseInvenTreeSetting(models.Model):
|
||||
return settings
|
||||
|
||||
@classmethod
|
||||
def get_setting_name(cls, key):
|
||||
def get_setting_definition(cls, key, **kwargs):
|
||||
"""
|
||||
Return the 'definition' of a particular settings value, as a dict object.
|
||||
|
||||
- The 'settings' dict can be passed as a kwarg
|
||||
- If not passed, look for cls.SETTINGS
|
||||
- Returns an empty dict if the key is not found
|
||||
"""
|
||||
|
||||
settings = kwargs.get('settings', cls.SETTINGS)
|
||||
|
||||
key = str(key).strip().upper()
|
||||
|
||||
if settings is not None and key in settings:
|
||||
return settings[key]
|
||||
else:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def get_setting_name(cls, key, **kwargs):
|
||||
"""
|
||||
Return the name of a particular setting.
|
||||
|
||||
If it does not exist, return an empty string.
|
||||
"""
|
||||
|
||||
key = str(key).strip().upper()
|
||||
|
||||
if key in cls.GLOBAL_SETTINGS:
|
||||
setting = cls.GLOBAL_SETTINGS[key]
|
||||
return setting.get('name', '')
|
||||
else:
|
||||
return ''
|
||||
setting = cls.get_setting_definition(key, **kwargs)
|
||||
return setting.get('name', '')
|
||||
|
||||
@classmethod
|
||||
def get_setting_description(cls, key):
|
||||
def get_setting_description(cls, key, **kwargs):
|
||||
"""
|
||||
Return the description for a particular setting.
|
||||
|
||||
If it does not exist, return an empty string.
|
||||
"""
|
||||
|
||||
key = str(key).strip().upper()
|
||||
setting = cls.get_setting_definition(key, **kwargs)
|
||||
|
||||
if key in cls.GLOBAL_SETTINGS:
|
||||
setting = cls.GLOBAL_SETTINGS[key]
|
||||
return setting.get('description', '')
|
||||
else:
|
||||
return ''
|
||||
return setting.get('description', '')
|
||||
|
||||
@classmethod
|
||||
def get_setting_units(cls, key):
|
||||
def get_setting_units(cls, key, **kwargs):
|
||||
"""
|
||||
Return the units for a particular setting.
|
||||
|
||||
If it does not exist, return an empty string.
|
||||
"""
|
||||
|
||||
key = str(key).strip().upper()
|
||||
setting = cls.get_setting_definition(key, **kwargs)
|
||||
|
||||
if key in cls.GLOBAL_SETTINGS:
|
||||
setting = cls.GLOBAL_SETTINGS[key]
|
||||
return setting.get('units', '')
|
||||
else:
|
||||
return ''
|
||||
return setting.get('units', '')
|
||||
|
||||
@classmethod
|
||||
def get_setting_validator(cls, key):
|
||||
def get_setting_validator(cls, key, **kwargs):
|
||||
"""
|
||||
Return the validator for a particular setting.
|
||||
|
||||
If it does not exist, return None
|
||||
"""
|
||||
|
||||
key = str(key).strip().upper()
|
||||
setting = cls.get_setting_definition(key, **kwargs)
|
||||
|
||||
if key in cls.GLOBAL_SETTINGS:
|
||||
setting = cls.GLOBAL_SETTINGS[key]
|
||||
return setting.get('validator', None)
|
||||
else:
|
||||
return None
|
||||
return setting.get('validator', None)
|
||||
|
||||
@classmethod
|
||||
def get_setting_default(cls, key):
|
||||
def get_setting_default(cls, key, **kwargs):
|
||||
"""
|
||||
Return the default value for a particular setting.
|
||||
|
||||
If it does not exist, return an empty string
|
||||
"""
|
||||
|
||||
key = str(key).strip().upper()
|
||||
setting = cls.get_setting_definition(key, **kwargs)
|
||||
|
||||
if key in cls.GLOBAL_SETTINGS:
|
||||
setting = cls.GLOBAL_SETTINGS[key]
|
||||
return setting.get('default', '')
|
||||
else:
|
||||
return ''
|
||||
return setting.get('default', '')
|
||||
|
||||
@classmethod
|
||||
def get_setting_choices(cls, key):
|
||||
def get_setting_choices(cls, key, **kwargs):
|
||||
"""
|
||||
Return the validator choices available for a particular setting.
|
||||
"""
|
||||
|
||||
key = str(key).strip().upper()
|
||||
setting = cls.get_setting_definition(key, **kwargs)
|
||||
|
||||
if key in cls.GLOBAL_SETTINGS:
|
||||
setting = cls.GLOBAL_SETTINGS[key]
|
||||
choices = setting.get('choices', None)
|
||||
else:
|
||||
choices = None
|
||||
choices = setting.get('choices', None)
|
||||
|
||||
if callable(choices):
|
||||
# Evaluate the function (we expect it will return a list of tuples...)
|
||||
@ -242,17 +245,40 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
key = str(key).strip().upper()
|
||||
|
||||
settings = cls.objects.all()
|
||||
|
||||
# Filter by user
|
||||
user = kwargs.get('user', None)
|
||||
|
||||
if user is not None:
|
||||
settings = settings.filter(user=user)
|
||||
|
||||
try:
|
||||
setting = cls.objects.filter(**cls.get_filters(key, **kwargs)).first()
|
||||
setting = settings.filter(**cls.get_filters(key, **kwargs)).first()
|
||||
except (ValueError, cls.DoesNotExist):
|
||||
setting = None
|
||||
except (IntegrityError, OperationalError):
|
||||
setting = None
|
||||
|
||||
plugin = kwargs.pop('plugin', None)
|
||||
|
||||
if plugin:
|
||||
from plugin import InvenTreePlugin
|
||||
|
||||
if issubclass(plugin.__class__, InvenTreePlugin):
|
||||
plugin = plugin.plugin_config()
|
||||
|
||||
kwargs['plugin'] = plugin
|
||||
|
||||
# Setting does not exist! (Try to create it)
|
||||
if not setting:
|
||||
|
||||
setting = cls(key=key, value=cls.get_setting_default(key), **kwargs)
|
||||
# Attempt to create a new settings object
|
||||
setting = cls(
|
||||
key=key,
|
||||
value=cls.get_setting_default(key, **kwargs),
|
||||
**kwargs
|
||||
)
|
||||
|
||||
try:
|
||||
# Wrap this statement in "atomic", so it can be rolled back if it fails
|
||||
@ -264,21 +290,6 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
return setting
|
||||
|
||||
@classmethod
|
||||
def get_setting_pk(cls, key):
|
||||
"""
|
||||
Return the primary-key value for a given setting.
|
||||
|
||||
If the setting does not exist, return None
|
||||
"""
|
||||
|
||||
setting = cls.get_setting_object(cls)
|
||||
|
||||
if setting:
|
||||
return setting.pk
|
||||
else:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_setting(cls, key, backup_value=None, **kwargs):
|
||||
"""
|
||||
@ -288,18 +299,19 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
# If no backup value is specified, atttempt to retrieve a "default" value
|
||||
if backup_value is None:
|
||||
backup_value = cls.get_setting_default(key)
|
||||
backup_value = cls.get_setting_default(key, **kwargs)
|
||||
|
||||
setting = cls.get_setting_object(key, **kwargs)
|
||||
|
||||
if setting:
|
||||
value = setting.value
|
||||
|
||||
# If the particular setting is defined as a boolean, cast the value to a boolean
|
||||
if setting.is_bool():
|
||||
# Cast to boolean if necessary
|
||||
if setting.is_bool(**kwargs):
|
||||
value = InvenTree.helpers.str2bool(value)
|
||||
|
||||
if setting.is_int():
|
||||
# Cast to integer if necessary
|
||||
if setting.is_int(**kwargs):
|
||||
try:
|
||||
value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
@ -362,7 +374,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
def units(self):
|
||||
return self.__class__.get_setting_units(self.key)
|
||||
|
||||
def clean(self):
|
||||
def clean(self, **kwargs):
|
||||
"""
|
||||
If a validator (or multiple validators) are defined for a particular setting key,
|
||||
run them against the 'value' field.
|
||||
@ -370,25 +382,16 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
super().clean()
|
||||
|
||||
validator = self.__class__.get_setting_validator(self.key)
|
||||
validator = self.__class__.get_setting_validator(self.key, **kwargs)
|
||||
|
||||
if self.is_bool():
|
||||
self.value = InvenTree.helpers.str2bool(self.value)
|
||||
|
||||
if self.is_int():
|
||||
try:
|
||||
self.value = int(self.value)
|
||||
except (ValueError):
|
||||
raise ValidationError(_('Must be an integer value'))
|
||||
if validator is not None:
|
||||
self.run_validator(validator)
|
||||
|
||||
options = self.valid_options()
|
||||
|
||||
if options and self.value not in options:
|
||||
raise ValidationError(_("Chosen value is not a valid option"))
|
||||
|
||||
if validator is not None:
|
||||
self.run_validator(validator)
|
||||
|
||||
def run_validator(self, validator):
|
||||
"""
|
||||
Run a validator against the 'value' field for this InvenTreeSetting object.
|
||||
@ -400,7 +403,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
value = self.value
|
||||
|
||||
# Boolean validator
|
||||
if self.is_bool():
|
||||
if validator is bool:
|
||||
# Value must "look like" a boolean value
|
||||
if InvenTree.helpers.is_bool(value):
|
||||
# Coerce into either "True" or "False"
|
||||
@ -411,7 +414,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
})
|
||||
|
||||
# Integer validator
|
||||
if self.is_int():
|
||||
if validator is int:
|
||||
|
||||
try:
|
||||
# Coerce into an integer value
|
||||
@ -464,12 +467,12 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
return [opt[0] for opt in choices]
|
||||
|
||||
def is_bool(self):
|
||||
def is_bool(self, **kwargs):
|
||||
"""
|
||||
Check if this setting is required to be a boolean value
|
||||
"""
|
||||
|
||||
validator = self.__class__.get_setting_validator(self.key)
|
||||
validator = self.__class__.get_setting_validator(self.key, **kwargs)
|
||||
|
||||
return self.__class__.validator_is_bool(validator)
|
||||
|
||||
@ -482,15 +485,15 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
return InvenTree.helpers.str2bool(self.value)
|
||||
|
||||
def setting_type(self):
|
||||
def setting_type(self, **kwargs):
|
||||
"""
|
||||
Return the field type identifier for this setting object
|
||||
"""
|
||||
|
||||
if self.is_bool():
|
||||
if self.is_bool(**kwargs):
|
||||
return 'boolean'
|
||||
|
||||
elif self.is_int():
|
||||
elif self.is_int(**kwargs):
|
||||
return 'integer'
|
||||
|
||||
else:
|
||||
@ -509,12 +512,12 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
return False
|
||||
|
||||
def is_int(self):
|
||||
def is_int(self, **kwargs):
|
||||
"""
|
||||
Check if the setting is required to be an integer value:
|
||||
"""
|
||||
|
||||
validator = self.__class__.get_setting_validator(self.key)
|
||||
validator = self.__class__.get_setting_validator(self.key, **kwargs)
|
||||
|
||||
return self.__class__.validator_is_int(validator)
|
||||
|
||||
@ -546,21 +549,20 @@ class BaseInvenTreeSetting(models.Model):
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def is_protected(cls, key):
|
||||
def is_protected(cls, key, **kwargs):
|
||||
"""
|
||||
Check if the setting value is protected
|
||||
"""
|
||||
|
||||
key = str(key).strip().upper()
|
||||
setting = cls.get_setting_definition(key, **kwargs)
|
||||
|
||||
if key in cls.GLOBAL_SETTINGS:
|
||||
return cls.GLOBAL_SETTINGS[key].get('protected', False)
|
||||
else:
|
||||
return False
|
||||
return setting.get('protected', False)
|
||||
|
||||
|
||||
def settings_group_options():
|
||||
"""build up group tuple for settings based on gour choices"""
|
||||
"""
|
||||
Build up group tuple for settings based on your choices
|
||||
"""
|
||||
return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]]
|
||||
|
||||
|
||||
@ -582,7 +584,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
super().save()
|
||||
|
||||
if self.requires_restart():
|
||||
InvenTreeSetting.set_setting('SERVER_REQUIRES_RESTART', True, None)
|
||||
InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None)
|
||||
|
||||
"""
|
||||
Dict of all global settings values:
|
||||
@ -600,7 +602,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
The keys must be upper-case
|
||||
"""
|
||||
|
||||
GLOBAL_SETTINGS = {
|
||||
SETTINGS = {
|
||||
|
||||
'SERVER_RESTART_REQUIRED': {
|
||||
'name': _('Restart required'),
|
||||
@ -883,13 +885,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'),
|
||||
@ -968,6 +963,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'),
|
||||
@ -982,16 +979,23 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
'requires_restart': True,
|
||||
},
|
||||
'ENABLE_PLUGINS_GLOBALSETTING': {
|
||||
'name': _('Enable global setting integration'),
|
||||
'description': _('Enable plugins to integrate into inventree global settings'),
|
||||
'ENABLE_PLUGINS_APP': {
|
||||
'name': _('Enable app integration'),
|
||||
'description': _('Enable plugins to add apps'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
'requires_restart': True,
|
||||
},
|
||||
'ENABLE_PLUGINS_APP': {
|
||||
'name': _('Enable app integration'),
|
||||
'description': _('Enable plugins to add apps'),
|
||||
'ENABLE_PLUGINS_SCHEDULE': {
|
||||
'name': _('Enable schedule integration'),
|
||||
'description': _('Enable plugins to run scheduled tasks'),
|
||||
'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,
|
||||
@ -1022,7 +1026,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
Return True if this setting requires a server restart after changing
|
||||
"""
|
||||
|
||||
options = InvenTreeSetting.GLOBAL_SETTINGS.get(self.key, None)
|
||||
options = InvenTreeSetting.SETTINGS.get(self.key, None)
|
||||
|
||||
if options:
|
||||
return options.get('requires_restart', False)
|
||||
@ -1035,7 +1039,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
An InvenTreeSetting object with a usercontext
|
||||
"""
|
||||
|
||||
GLOBAL_SETTINGS = {
|
||||
SETTINGS = {
|
||||
'HOMEPAGE_PART_STARRED': {
|
||||
'name': _('Show subscribed parts'),
|
||||
'description': _('Show subscribed parts on the homepage'),
|
||||
@ -1400,6 +1404,184 @@ class ColorTheme(models.Model):
|
||||
return False
|
||||
|
||||
|
||||
class VerificationMethod:
|
||||
NONE = 0
|
||||
TOKEN = 1
|
||||
HMAC = 2
|
||||
|
||||
|
||||
class WebhookEndpoint(models.Model):
|
||||
""" Defines a Webhook entdpoint
|
||||
|
||||
Attributes:
|
||||
endpoint_id: Path to the webhook,
|
||||
name: Name of the webhook,
|
||||
active: Is this webhook active?,
|
||||
user: User associated with webhook,
|
||||
token: Token for sending a webhook,
|
||||
secret: Shared secret for HMAC verification,
|
||||
"""
|
||||
|
||||
# Token
|
||||
TOKEN_NAME = "Token"
|
||||
VERIFICATION_METHOD = VerificationMethod.NONE
|
||||
|
||||
MESSAGE_OK = "Message was received."
|
||||
MESSAGE_TOKEN_ERROR = "Incorrect token in header."
|
||||
|
||||
endpoint_id = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_('Endpoint'),
|
||||
help_text=_('Endpoint at which this webhook is received'),
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Name'),
|
||||
help_text=_('Name for this webhook')
|
||||
)
|
||||
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_('Active'),
|
||||
help_text=_('Is this webhook active')
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('User'),
|
||||
help_text=_('User'),
|
||||
)
|
||||
|
||||
token = models.CharField(
|
||||
max_length=255,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Token'),
|
||||
help_text=_('Token for access'),
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
|
||||
secret = models.CharField(
|
||||
max_length=255,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Secret'),
|
||||
help_text=_('Shared secret for HMAC'),
|
||||
)
|
||||
|
||||
# To be overridden
|
||||
|
||||
def init(self, request, *args, **kwargs):
|
||||
self.verify = self.VERIFICATION_METHOD
|
||||
|
||||
def process_webhook(self):
|
||||
if self.token:
|
||||
self.token = self.token
|
||||
self.verify = VerificationMethod.TOKEN
|
||||
# TODO make a object-setting
|
||||
if self.secret:
|
||||
self.secret = self.secret
|
||||
self.verify = VerificationMethod.HMAC
|
||||
# TODO make a object-setting
|
||||
return True
|
||||
|
||||
def validate_token(self, payload, headers, request):
|
||||
token = headers.get(self.TOKEN_NAME, "")
|
||||
|
||||
# no token
|
||||
if self.verify == VerificationMethod.NONE:
|
||||
pass
|
||||
|
||||
# static token
|
||||
elif self.verify == VerificationMethod.TOKEN:
|
||||
if not compare_digest(token, self.token):
|
||||
raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
|
||||
|
||||
# hmac token
|
||||
elif self.verify == VerificationMethod.HMAC:
|
||||
digest = hmac.new(self.secret.encode('utf-8'), request.body, hashlib.sha256).digest()
|
||||
computed_hmac = base64.b64encode(digest)
|
||||
if not hmac.compare_digest(computed_hmac, token.encode('utf-8')):
|
||||
raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
|
||||
|
||||
return True
|
||||
|
||||
def save_data(self, payload, headers=None, request=None):
|
||||
return WebhookMessage.objects.create(
|
||||
host=request.get_host(),
|
||||
header=json.dumps({key: val for key, val in headers.items()}),
|
||||
body=payload,
|
||||
endpoint=self,
|
||||
)
|
||||
|
||||
def process_payload(self, message, payload=None, headers=None):
|
||||
return True
|
||||
|
||||
def get_return(self, payload, headers=None, request=None):
|
||||
return self.MESSAGE_OK
|
||||
|
||||
|
||||
class WebhookMessage(models.Model):
|
||||
""" Defines a webhook message
|
||||
|
||||
Attributes:
|
||||
message_id: Unique identifier for this message,
|
||||
host: Host from which this message was received,
|
||||
header: Header of this message,
|
||||
body: Body of this message,
|
||||
endpoint: Endpoint on which this message was received,
|
||||
worked_on: Was the work on this message finished?
|
||||
"""
|
||||
|
||||
message_id = models.UUIDField(
|
||||
verbose_name=_('Message ID'),
|
||||
help_text=_('Unique identifier for this message'),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
host = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_('Host'),
|
||||
help_text=_('Host from which this message was received'),
|
||||
editable=False,
|
||||
)
|
||||
|
||||
header = models.CharField(
|
||||
max_length=255,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Header'),
|
||||
help_text=_('Header of this message'),
|
||||
editable=False,
|
||||
)
|
||||
|
||||
body = models.JSONField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Body'),
|
||||
help_text=_('Body of this message'),
|
||||
editable=False,
|
||||
)
|
||||
|
||||
endpoint = models.ForeignKey(
|
||||
WebhookEndpoint,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Endpoint'),
|
||||
help_text=_('Endpoint on which this message was received'),
|
||||
)
|
||||
|
||||
worked_on = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Worked on'),
|
||||
help_text=_('Was the work on this message finished?'),
|
||||
)
|
||||
|
||||
|
||||
class NotificationEntry(models.Model):
|
||||
"""
|
||||
A NotificationEntry records the last time a particular notifaction was sent out.
|
||||
|
@ -1,13 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from .models import InvenTreeSetting
|
||||
from .models import NotificationEntry
|
||||
from .models import InvenTreeSetting, WebhookEndpoint, WebhookMessage, NotificationEntry
|
||||
from .api import WebhookView
|
||||
|
||||
|
||||
class SettingsTest(TestCase):
|
||||
@ -49,9 +50,9 @@ class SettingsTest(TestCase):
|
||||
- Ensure that every global setting has a description.
|
||||
"""
|
||||
|
||||
for key in InvenTreeSetting.GLOBAL_SETTINGS.keys():
|
||||
for key in InvenTreeSetting.SETTINGS.keys():
|
||||
|
||||
setting = InvenTreeSetting.GLOBAL_SETTINGS[key]
|
||||
setting = InvenTreeSetting.SETTINGS[key]
|
||||
|
||||
name = setting.get('name', None)
|
||||
|
||||
@ -64,14 +65,14 @@ class SettingsTest(TestCase):
|
||||
raise ValueError(f'Missing GLOBAL_SETTING description for {key}')
|
||||
|
||||
if not key == key.upper():
|
||||
raise ValueError(f"GLOBAL_SETTINGS key '{key}' is not uppercase")
|
||||
raise ValueError(f"SETTINGS key '{key}' is not uppercase")
|
||||
|
||||
def test_defaults(self):
|
||||
"""
|
||||
Populate the settings with default values
|
||||
"""
|
||||
|
||||
for key in InvenTreeSetting.GLOBAL_SETTINGS.keys():
|
||||
for key in InvenTreeSetting.SETTINGS.keys():
|
||||
|
||||
value = InvenTreeSetting.get_setting_default(key)
|
||||
|
||||
@ -90,7 +91,119 @@ class SettingsTest(TestCase):
|
||||
raise ValueError(f'Non-boolean default value specified for {key}')
|
||||
|
||||
|
||||
class NotificationEntryTest(TestCase):
|
||||
class WebhookMessageTests(TestCase):
|
||||
def setUp(self):
|
||||
self.endpoint_def = WebhookEndpoint.objects.create()
|
||||
self.url = f'/api/webhook/{self.endpoint_def.endpoint_id}/'
|
||||
self.client = Client(enforce_csrf_checks=True)
|
||||
|
||||
def test_bad_method(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
|
||||
|
||||
def test_missing_token(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
assert (
|
||||
json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR
|
||||
)
|
||||
|
||||
def test_bad_token(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
content_type='application/json',
|
||||
**{'HTTP_TOKEN': '1234567fghj'},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
assert (json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR)
|
||||
|
||||
def test_bad_url(self):
|
||||
response = self.client.post(
|
||||
'/api/webhook/1234/',
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
def test_bad_json(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data="{'this': 123}",
|
||||
content_type='application/json',
|
||||
**{'HTTP_TOKEN': str(self.endpoint_def.token)},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_ACCEPTABLE
|
||||
assert (
|
||||
json.loads(response.content)['detail'] == 'Expecting property name enclosed in double quotes'
|
||||
)
|
||||
|
||||
def test_success_no_token_check(self):
|
||||
# delete token
|
||||
self.endpoint_def.token = ''
|
||||
self.endpoint_def.save()
|
||||
|
||||
# check
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK
|
||||
|
||||
def test_bad_hmac(self):
|
||||
# delete token
|
||||
self.endpoint_def.token = ''
|
||||
self.endpoint_def.secret = '123abc'
|
||||
self.endpoint_def.save()
|
||||
|
||||
# check
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
assert (json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR)
|
||||
|
||||
def test_success_hmac(self):
|
||||
# delete token
|
||||
self.endpoint_def.token = ''
|
||||
self.endpoint_def.secret = '123abc'
|
||||
self.endpoint_def.save()
|
||||
|
||||
# check
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
content_type='application/json',
|
||||
**{'HTTP_TOKEN': str('68MXtc/OiXdA5e2Nq9hATEVrZFpLb3Zb0oau7n8s31I=')},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK
|
||||
|
||||
def test_success(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data={"this": "is a message"},
|
||||
content_type='application/json',
|
||||
**{'HTTP_TOKEN': str(self.endpoint_def.token)},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK
|
||||
message = WebhookMessage.objects.get()
|
||||
assert message.body == {"this": "is a message"}
|
||||
|
||||
|
||||
class NotificationTest(TestCase):
|
||||
|
||||
def test_check_notification_entries(self):
|
||||
|
||||
|
@ -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
|
||||
|
||||
@ -101,6 +102,10 @@ debug: True
|
||||
# and only if InvenTree is accessed from a local IP (127.0.0.1)
|
||||
debug_toolbar: False
|
||||
|
||||
# Set this variable to True to enable InvenTree Plugins
|
||||
# Alternatively, use the environment variable INVENTREE_PLUGINS_ENABLED
|
||||
plugins_enabled: False
|
||||
|
||||
# Configure the system logging level
|
||||
# Use environment variable INVENTREE_LOG_LEVEL
|
||||
# Options: DEBUG / INFO / WARNING / ERROR / CRITICAL
|
||||
|
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
@ -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
|
||||
|
@ -1,11 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
""" This module provides template tags for extra functionality
|
||||
"""
|
||||
This module provides template tags for extra functionality,
|
||||
over and above the built-in Django tags.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from django.utils.html import format_html
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
@ -15,6 +17,7 @@ from django import template
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.templatetags.static import StaticNode
|
||||
|
||||
from InvenTree import version, settings
|
||||
|
||||
import InvenTree.helpers
|
||||
@ -22,6 +25,8 @@ import InvenTree.helpers
|
||||
from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from plugin.models import PluginSetting
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@ -104,6 +109,13 @@ def inventree_docker_mode(*args, **kwargs):
|
||||
return djangosettings.DOCKER
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def plugins_enabled(*args, **kwargs):
|
||||
""" Return True if plugins are enabled for the server instance """
|
||||
|
||||
return djangosettings.PLUGINS_ENABLED
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_db_engine(*args, **kwargs):
|
||||
""" Return the InvenTree database backend e.g. 'postgresql' """
|
||||
@ -223,8 +235,16 @@ def setting_object(key, *args, **kwargs):
|
||||
if a user-setting was requested return that
|
||||
"""
|
||||
|
||||
if 'plugin' in kwargs:
|
||||
# Note, 'plugin' is an instance of an InvenTreePlugin class
|
||||
|
||||
plugin = kwargs['plugin']
|
||||
|
||||
return PluginSetting.get_setting_object(key, plugin=plugin)
|
||||
|
||||
if 'user' in kwargs:
|
||||
return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'])
|
||||
|
||||
return InvenTreeSetting.get_setting_object(key)
|
||||
|
||||
|
||||
|
@ -1,7 +1,15 @@
|
||||
from .registry import plugins as plugin_reg
|
||||
"""
|
||||
Utility file to enable simper imports
|
||||
"""
|
||||
|
||||
from .registry import plugin_registry
|
||||
from .plugin import InvenTreePlugin
|
||||
from .integration import IntegrationPluginBase
|
||||
from .action import ActionPlugin
|
||||
|
||||
__all__ = [
|
||||
'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin',
|
||||
'ActionPlugin',
|
||||
'IntegrationPluginBase',
|
||||
'InvenTreePlugin',
|
||||
'plugin_registry',
|
||||
]
|
||||
|
@ -4,43 +4,70 @@ from __future__ import unicode_literals
|
||||
from django.contrib import admin
|
||||
|
||||
import plugin.models as models
|
||||
from plugin import plugin_reg
|
||||
import plugin.registry as registry
|
||||
|
||||
|
||||
def plugin_update(queryset, new_status: bool):
|
||||
"""general function for bulk changing plugins"""
|
||||
"""
|
||||
General function for bulk changing plugins
|
||||
"""
|
||||
|
||||
apps_changed = False
|
||||
|
||||
# run through all plugins in the queryset as the save method needs to be overridden
|
||||
# Run through all plugins in the queryset as the save method needs to be overridden
|
||||
for plugin in queryset:
|
||||
if plugin.active is not new_status:
|
||||
plugin.active = new_status
|
||||
plugin.save(no_reload=True)
|
||||
apps_changed = True
|
||||
|
||||
# reload plugins if they changed
|
||||
# Reload plugins if they changed
|
||||
if apps_changed:
|
||||
plugin_reg.reload_plugins()
|
||||
registry.plugin_registry.reload_plugins()
|
||||
|
||||
|
||||
@admin.action(description='Activate plugin(s)')
|
||||
def plugin_activate(modeladmin, request, queryset):
|
||||
"""activate a set of plugins"""
|
||||
"""
|
||||
Activate a set of plugins
|
||||
"""
|
||||
plugin_update(queryset, True)
|
||||
|
||||
|
||||
@admin.action(description='Deactivate plugin(s)')
|
||||
def plugin_deactivate(modeladmin, request, queryset):
|
||||
"""deactivate a set of plugins"""
|
||||
"""
|
||||
Deactivate a set of plugins
|
||||
"""
|
||||
|
||||
plugin_update(queryset, False)
|
||||
|
||||
|
||||
class PluginSettingInline(admin.TabularInline):
|
||||
"""
|
||||
Inline admin class for PluginSetting
|
||||
"""
|
||||
|
||||
model = models.PluginSetting
|
||||
|
||||
read_only_fields = [
|
||||
'key',
|
||||
]
|
||||
|
||||
def has_add_permission(self, request, obj):
|
||||
return False
|
||||
|
||||
|
||||
class PluginConfigAdmin(admin.ModelAdmin):
|
||||
"""Custom admin with restricted id fields"""
|
||||
"""
|
||||
Custom admin with restricted id fields
|
||||
"""
|
||||
|
||||
readonly_fields = ["key", "name", ]
|
||||
list_display = ['active', '__str__', 'key', 'name', ]
|
||||
list_display = ['name', 'key', '__str__', 'active', ]
|
||||
list_filter = ['active']
|
||||
actions = [plugin_activate, plugin_deactivate, ]
|
||||
inlines = [PluginSettingInline, ]
|
||||
|
||||
|
||||
admin.site.register(models.PluginConfig, PluginConfigAdmin)
|
||||
|
@ -11,7 +11,8 @@ from rest_framework import generics
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plugin.models import PluginConfig
|
||||
from common.api import GlobalSettingsPermissions
|
||||
from plugin.models import PluginConfig, PluginSetting
|
||||
import plugin.serializers as PluginSerializers
|
||||
|
||||
|
||||
@ -76,7 +77,46 @@ class PluginInstall(generics.CreateAPIView):
|
||||
return serializer.save()
|
||||
|
||||
|
||||
class PluginSettingList(generics.ListAPIView):
|
||||
"""
|
||||
List endpoint for all plugin related settings.
|
||||
|
||||
- read only
|
||||
- only accessible by staff users
|
||||
"""
|
||||
|
||||
queryset = PluginSetting.objects.all()
|
||||
serializer_class = PluginSerializers.PluginSettingSerializer
|
||||
|
||||
permission_classes = [
|
||||
GlobalSettingsPermissions,
|
||||
]
|
||||
|
||||
|
||||
class PluginSettingDetail(generics.RetrieveUpdateAPIView):
|
||||
"""
|
||||
Detail endpoint for a plugin-specific setting.
|
||||
|
||||
Note that these cannot be created or deleted via the API
|
||||
"""
|
||||
|
||||
queryset = PluginSetting.objects.all()
|
||||
serializer_class = PluginSerializers.PluginSettingSerializer
|
||||
|
||||
# Staff permission required
|
||||
permission_classes = [
|
||||
GlobalSettingsPermissions,
|
||||
]
|
||||
|
||||
|
||||
plugin_api_urls = [
|
||||
|
||||
# Plugin settings URLs
|
||||
url(r'^settings/', include([
|
||||
url(r'^(?P<pk>\d+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'),
|
||||
url(r'^.*$', PluginSettingList.as_view(), name='api-plugin-setting-list'),
|
||||
])),
|
||||
|
||||
# Detail views for a single PluginConfig item
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
|
||||
|
@ -1,21 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
|
||||
from maintenance_mode.core import set_maintenance_mode
|
||||
|
||||
from plugin.registry import plugins
|
||||
from plugin import plugin_registry
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class PluginAppConfig(AppConfig):
|
||||
name = 'plugin'
|
||||
|
||||
def ready(self):
|
||||
if not plugins.is_loading:
|
||||
# this is the first startup
|
||||
plugins.collect_plugins()
|
||||
plugins.load_plugins()
|
||||
|
||||
# drop out of maintenance
|
||||
# makes sure we did not have an error in reloading and maintenance is still active
|
||||
set_maintenance_mode(False)
|
||||
if settings.PLUGINS_ENABLED:
|
||||
logger.info('Loading InvenTree plugins')
|
||||
|
||||
if not plugin_registry.is_loading:
|
||||
# this is the first startup
|
||||
plugin_registry.collect_plugins()
|
||||
plugin_registry.load_plugins()
|
||||
|
||||
# drop out of maintenance
|
||||
# makes sure we did not have an error in reloading and maintenance is still active
|
||||
set_maintenance_mode(False)
|
||||
|
@ -2,11 +2,20 @@
|
||||
Plugin mixin classes
|
||||
"""
|
||||
|
||||
from django.conf.urls import url, include
|
||||
import logging
|
||||
import json
|
||||
import requests
|
||||
|
||||
from django.conf.urls import url, include
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
from plugin.models import PluginConfig, PluginSetting
|
||||
from plugin.urls import PLUGIN_BASE
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class SettingsMixin:
|
||||
"""
|
||||
Mixin that enables global settings for the plugin
|
||||
@ -17,44 +26,180 @@ class SettingsMixin:
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_mixin('settings', 'has_globalsettings', __class__)
|
||||
self.globalsettings = getattr(self, 'SETTINGS', None)
|
||||
self.add_mixin('settings', 'has_settings', __class__)
|
||||
self.settings = getattr(self, 'SETTINGS', {})
|
||||
|
||||
@property
|
||||
def has_globalsettings(self):
|
||||
def has_settings(self):
|
||||
"""
|
||||
Does this plugin use custom global settings
|
||||
"""
|
||||
return bool(self.globalsettings)
|
||||
return bool(self.settings)
|
||||
|
||||
def get_setting(self, key):
|
||||
"""
|
||||
Return the 'value' of the setting associated with this plugin
|
||||
"""
|
||||
|
||||
return PluginSetting.get_setting(key, plugin=self)
|
||||
|
||||
def set_setting(self, key, value, user=None):
|
||||
"""
|
||||
Set plugin setting value by key
|
||||
"""
|
||||
|
||||
try:
|
||||
plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
|
||||
except (OperationalError, ProgrammingError):
|
||||
plugin = None
|
||||
|
||||
if not plugin:
|
||||
# Cannot find associated plugin model, return
|
||||
return
|
||||
|
||||
PluginSetting.set_setting(key, value, user, plugin=plugin)
|
||||
|
||||
|
||||
class ScheduleMixin:
|
||||
"""
|
||||
Mixin that provides support for scheduled tasks.
|
||||
|
||||
Implementing classes must provide a dict object called SCHEDULED_TASKS,
|
||||
which provides information on the tasks to be scheduled.
|
||||
|
||||
SCHEDULED_TASKS = {
|
||||
# Name of the task (will be prepended with the plugin name)
|
||||
'test_server': {
|
||||
'func': 'myplugin.tasks.test_server', # Python function to call (no arguments!)
|
||||
'schedule': "I", # Schedule type (see django_q.Schedule)
|
||||
'minutes': 30, # Number of minutes (only if schedule type = Minutes)
|
||||
'repeats': 5, # Number of repeats (leave blank for 'forever')
|
||||
}
|
||||
}
|
||||
|
||||
Note: 'schedule' parameter must be one of ['I', 'H', 'D', 'W', 'M', 'Q', 'Y']
|
||||
"""
|
||||
|
||||
ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y']
|
||||
|
||||
# Override this in subclass model
|
||||
SCHEDULED_TASKS = {}
|
||||
|
||||
class MixinMeta:
|
||||
MIXIN_NAME = 'Schedule'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_mixin('schedule', 'has_scheduled_tasks', __class__)
|
||||
self.scheduled_tasks = getattr(self, 'SCHEDULED_TASKS', {})
|
||||
|
||||
self.validate_scheduled_tasks()
|
||||
|
||||
@property
|
||||
def globalsettingspatterns(self):
|
||||
"""
|
||||
Get patterns for InvenTreeSetting defintion
|
||||
"""
|
||||
if self.has_globalsettings:
|
||||
return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.globalsettings.items()}
|
||||
return None
|
||||
def has_scheduled_tasks(self):
|
||||
return bool(self.scheduled_tasks)
|
||||
|
||||
def _globalsetting_name(self, key):
|
||||
def validate_scheduled_tasks(self):
|
||||
"""
|
||||
Get global name of setting
|
||||
Check that the provided scheduled tasks are valid
|
||||
"""
|
||||
return f'PLUGIN_{self.slug.upper()}_{key}'
|
||||
|
||||
def get_globalsetting(self, key):
|
||||
"""
|
||||
get plugin global setting by key
|
||||
"""
|
||||
from common.models import InvenTreeSetting
|
||||
return InvenTreeSetting.get_setting(self._globalsetting_name(key))
|
||||
if not self.has_scheduled_tasks:
|
||||
raise ValueError("SCHEDULED_TASKS not defined")
|
||||
|
||||
def set_globalsetting(self, key, value, user):
|
||||
for key, task in self.scheduled_tasks.items():
|
||||
|
||||
if 'func' not in task:
|
||||
raise ValueError(f"Task '{key}' is missing 'func' parameter")
|
||||
|
||||
if 'schedule' not in task:
|
||||
raise ValueError(f"Task '{key}' is missing 'schedule' parameter")
|
||||
|
||||
schedule = task['schedule'].upper().strip()
|
||||
|
||||
if schedule not in self.ALLOWABLE_SCHEDULE_TYPES:
|
||||
raise ValueError(f"Task '{key}': Schedule '{schedule}' is not a valid option")
|
||||
|
||||
# If 'minutes' is selected, it must be provided!
|
||||
if schedule == 'I' and 'minutes' not in task:
|
||||
raise ValueError(f"Task '{key}' is missing 'minutes' parameter")
|
||||
|
||||
def get_task_name(self, key):
|
||||
# Generate a 'unique' task name
|
||||
slug = self.plugin_slug()
|
||||
return f"plugin.{slug}.{key}"
|
||||
|
||||
def get_task_names(self):
|
||||
# Returns a list of all task names associated with this plugin instance
|
||||
return [self.get_task_name(key) for key in self.scheduled_tasks.keys()]
|
||||
|
||||
def register_tasks(self):
|
||||
"""
|
||||
set plugin global setting by key
|
||||
Register the tasks with the database
|
||||
"""
|
||||
from common.models import InvenTreeSetting
|
||||
return InvenTreeSetting.set_setting(self._globalsetting_name(key), value, user)
|
||||
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
|
||||
for key, task in self.scheduled_tasks.items():
|
||||
|
||||
task_name = self.get_task_name(key)
|
||||
|
||||
# If a matching scheduled task does not exist, create it!
|
||||
if not Schedule.objects.filter(name=task_name).exists():
|
||||
|
||||
logger.info(f"Adding scheduled task '{task_name}'")
|
||||
|
||||
Schedule.objects.create(
|
||||
name=task_name,
|
||||
func=task['func'],
|
||||
schedule_type=task['schedule'],
|
||||
minutes=task.get('minutes', None),
|
||||
repeats=task.get('repeats', -1),
|
||||
)
|
||||
except (ProgrammingError, OperationalError):
|
||||
# Database might not yet be ready
|
||||
logger.warning("register_tasks failed, database not ready")
|
||||
|
||||
def unregister_tasks(self):
|
||||
"""
|
||||
Deregister the tasks with the database
|
||||
"""
|
||||
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
|
||||
for key, task in self.scheduled_tasks.items():
|
||||
|
||||
task_name = self.get_task_name(key)
|
||||
|
||||
try:
|
||||
scheduled_task = Schedule.objects.get(name=task_name)
|
||||
scheduled_task.delete()
|
||||
except Schedule.DoesNotExist:
|
||||
pass
|
||||
except (ProgrammingError, OperationalError):
|
||||
# Database might not yet be 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:
|
||||
@ -116,7 +261,9 @@ class NavigationMixin:
|
||||
NAVIGATION_TAB_ICON = "fas fa-question"
|
||||
|
||||
class MixinMeta:
|
||||
"""meta options for this mixin"""
|
||||
"""
|
||||
meta options for this mixin
|
||||
"""
|
||||
MIXIN_NAME = 'Navigation Links'
|
||||
|
||||
def __init__(self):
|
||||
@ -176,3 +323,113 @@ class AppMixin:
|
||||
this plugin is always an app with this plugin
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
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
|
||||
|
188
InvenTree/plugin/events.py
Normal file
188
InvenTree/plugin/events.py
Normal file
@ -0,0 +1,188 @@
|
||||
"""
|
||||
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 settings.PLUGINS_ENABLED:
|
||||
# Do nothing if plugins are not enabled
|
||||
return
|
||||
|
||||
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',
|
||||
'common_webhookendpoint',
|
||||
'common_webhookmessage',
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
instance_id = getattr(instance, 'id', None)
|
||||
|
||||
if instance_id is None:
|
||||
return
|
||||
|
||||
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,
|
||||
)
|
@ -10,14 +10,14 @@ from django.conf import settings
|
||||
|
||||
# region logging / errors
|
||||
def log_plugin_error(error, reference: str = 'general'):
|
||||
from plugin import plugin_reg
|
||||
from plugin import plugin_registry
|
||||
|
||||
# make sure the registry is set up
|
||||
if reference not in plugin_reg.errors:
|
||||
plugin_reg.errors[reference] = []
|
||||
if reference not in plugin_registry.errors:
|
||||
plugin_registry.errors[reference] = []
|
||||
|
||||
# add error to stack
|
||||
plugin_reg.errors[reference].append(error)
|
||||
plugin_registry.errors[reference].append(error)
|
||||
|
||||
|
||||
class IntegrationPluginError(Exception):
|
||||
|
@ -9,7 +9,6 @@ import pathlib
|
||||
|
||||
from django.urls.base import reverse
|
||||
from django.conf import settings
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
import plugin.plugin as plugin
|
||||
@ -20,19 +19,27 @@ logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
class MixinBase:
|
||||
"""general base for mixins"""
|
||||
"""
|
||||
General base for mixins
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mixinreg = {}
|
||||
self._mixins = {}
|
||||
|
||||
def add_mixin(self, key: str, fnc_enabled=True, cls=None):
|
||||
"""add a mixin to the plugins registry"""
|
||||
"""
|
||||
Add a mixin to the plugins registry
|
||||
"""
|
||||
|
||||
self._mixins[key] = fnc_enabled
|
||||
self.setup_mixin(key, cls=cls)
|
||||
|
||||
def setup_mixin(self, key, cls=None):
|
||||
"""define mixin details for the current mixin -> provides meta details for all active mixins"""
|
||||
"""
|
||||
Define mixin details for the current mixin -> provides meta details for all active mixins
|
||||
"""
|
||||
|
||||
# get human name
|
||||
human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
|
||||
|
||||
@ -44,7 +51,10 @@ class MixinBase:
|
||||
|
||||
@property
|
||||
def registered_mixins(self, with_base: bool = False):
|
||||
"""get all registered mixins for the plugin"""
|
||||
"""
|
||||
Get all registered mixins for the plugin
|
||||
"""
|
||||
|
||||
mixins = getattr(self, '_mixinreg', None)
|
||||
if mixins:
|
||||
# filter out base
|
||||
@ -59,8 +69,6 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
|
||||
"""
|
||||
The IntegrationPluginBase class is used to integrate with 3rd party software
|
||||
"""
|
||||
PLUGIN_SLUG = None
|
||||
PLUGIN_TITLE = None
|
||||
|
||||
AUTHOR = None
|
||||
DESCRIPTION = None
|
||||
@ -84,11 +92,11 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
|
||||
# region properties
|
||||
@property
|
||||
def slug(self):
|
||||
"""slug for the plugin"""
|
||||
slug = getattr(self, 'PLUGIN_SLUG', None)
|
||||
if not slug:
|
||||
slug = self.plugin_name()
|
||||
return slugify(slug)
|
||||
return self.plugin_slug()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.plugin_name()
|
||||
|
||||
@property
|
||||
def human_name(self):
|
||||
@ -168,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
|
||||
|
@ -4,7 +4,7 @@ load templates for loaded plugins
|
||||
from django.template.loaders.filesystem import Loader as FilesystemLoader
|
||||
from pathlib import Path
|
||||
|
||||
from plugin import plugin_reg
|
||||
from plugin import plugin_registry
|
||||
|
||||
|
||||
class PluginTemplateLoader(FilesystemLoader):
|
||||
@ -12,7 +12,7 @@ class PluginTemplateLoader(FilesystemLoader):
|
||||
def get_dirs(self):
|
||||
dirname = 'templates'
|
||||
template_dirs = []
|
||||
for plugin in plugin_reg.plugins.values():
|
||||
for plugin in plugin_registry.plugins.values():
|
||||
new_path = Path(plugin.path) / dirname
|
||||
if Path(new_path).is_dir():
|
||||
template_dirs.append(new_path)
|
||||
|
26
InvenTree/plugin/migrations/0003_pluginsetting.py
Normal file
26
InvenTree/plugin/migrations/0003_pluginsetting.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.2.10 on 2022-01-01 10:52
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('plugin', '0002_alter_pluginconfig_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PluginSetting',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(help_text='Settings key (must be unique - case insensitive', max_length=50)),
|
||||
('value', models.CharField(blank=True, help_text='Settings value', max_length=200)),
|
||||
('plugin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='plugin.pluginconfig', verbose_name='Plugin')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('plugin', 'key')},
|
||||
},
|
||||
),
|
||||
]
|
@ -1,9 +1,15 @@
|
||||
"""utility class to enable simpler imports"""
|
||||
from ..builtin.integration.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin
|
||||
"""
|
||||
Utility class to enable simpler imports
|
||||
"""
|
||||
|
||||
from ..builtin.integration.mixins import APICallMixin, AppMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
|
||||
|
||||
__all__ = [
|
||||
'APICallMixin',
|
||||
'AppMixin',
|
||||
'EventMixin',
|
||||
'NavigationMixin',
|
||||
'ScheduleMixin',
|
||||
'SettingsMixin',
|
||||
'UrlsMixin',
|
||||
]
|
||||
|
@ -8,16 +8,17 @@ from __future__ import unicode_literals
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from plugin import plugin_reg
|
||||
import common.models
|
||||
|
||||
from plugin import InvenTreePlugin, plugin_registry
|
||||
|
||||
|
||||
class PluginConfig(models.Model):
|
||||
""" A PluginConfig object holds settings for plugins.
|
||||
|
||||
It is used to designate a Part as 'subscribed' for a given User.
|
||||
"""
|
||||
A PluginConfig object holds settings for plugins.
|
||||
|
||||
Attributes:
|
||||
key: slug of the plugin - must be unique
|
||||
key: slug of the plugin (this must be unique across all installed plugins!)
|
||||
name: PluginName of the plugin - serves for a manual double check if the right plugin is used
|
||||
active: Should the plugin be loaded?
|
||||
"""
|
||||
@ -63,12 +64,15 @@ class PluginConfig(models.Model):
|
||||
# functions
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""override to set original state of"""
|
||||
"""
|
||||
Override to set original state of the plugin-config instance
|
||||
"""
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__org_active = self.active
|
||||
|
||||
# append settings from registry
|
||||
self.plugin = plugin_reg.plugins.get(self.key, None)
|
||||
self.plugin = plugin_registry.plugins.get(self.key, None)
|
||||
|
||||
def get_plugin_meta(name):
|
||||
if self.plugin:
|
||||
@ -82,16 +86,112 @@ class PluginConfig(models.Model):
|
||||
}
|
||||
|
||||
def save(self, force_insert=False, force_update=False, *args, **kwargs):
|
||||
"""extend save method to reload plugins if the 'active' status changes"""
|
||||
"""
|
||||
Extend save method to reload plugins if the 'active' status changes
|
||||
"""
|
||||
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
||||
|
||||
ret = super().save(force_insert, force_update, *args, **kwargs)
|
||||
|
||||
if not reload:
|
||||
if self.active is False and self.__org_active is True:
|
||||
plugin_reg.reload_plugins()
|
||||
plugin_registry.reload_plugins()
|
||||
|
||||
elif self.active is True and self.__org_active is False:
|
||||
plugin_reg.reload_plugins()
|
||||
plugin_registry.reload_plugins()
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class PluginSetting(common.models.BaseInvenTreeSetting):
|
||||
"""
|
||||
This model represents settings for individual plugins
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
unique_together = [
|
||||
('plugin', 'key'),
|
||||
]
|
||||
|
||||
def clean(self, **kwargs):
|
||||
|
||||
kwargs['plugin'] = self.plugin
|
||||
|
||||
super().clean(**kwargs)
|
||||
|
||||
"""
|
||||
We override the following class methods,
|
||||
so that we can pass the plugin instance
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.__class__.get_setting_name(self.key, plugin=self.plugin)
|
||||
|
||||
@property
|
||||
def default_value(self):
|
||||
return self.__class__.get_setting_default(self.key, plugin=self.plugin)
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return self.__class__.get_setting_description(self.key, plugin=self.plugin)
|
||||
|
||||
@property
|
||||
def units(self):
|
||||
return self.__class__.get_setting_units(self.key, plugin=self.plugin)
|
||||
|
||||
def choices(self):
|
||||
return self.__class__.get_setting_choices(self.key, plugin=self.plugin)
|
||||
|
||||
@classmethod
|
||||
def get_setting_definition(cls, key, **kwargs):
|
||||
"""
|
||||
In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS',
|
||||
which is a dict object that fully defines all the setting parameters.
|
||||
|
||||
Here, unlike the BaseInvenTreeSetting, we do not know the definitions of all settings
|
||||
'ahead of time' (as they are defined externally in the plugins).
|
||||
|
||||
Settings can be provided by the caller, as kwargs['settings'].
|
||||
|
||||
If not provided, we'll look at the plugin registry to see what settings are available,
|
||||
(if the plugin is specified!)
|
||||
"""
|
||||
|
||||
if 'settings' not in kwargs:
|
||||
|
||||
plugin = kwargs.pop('plugin', None)
|
||||
|
||||
if plugin:
|
||||
|
||||
if issubclass(plugin.__class__, InvenTreePlugin):
|
||||
plugin = plugin.plugin_config()
|
||||
|
||||
kwargs['settings'] = plugin_registry.mixins_settings.get(plugin.key, {})
|
||||
|
||||
return super().get_setting_definition(key, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_filters(cls, key, **kwargs):
|
||||
"""
|
||||
Override filters method to ensure settings are filtered by plugin id
|
||||
"""
|
||||
|
||||
filters = super().get_filters(key, **kwargs)
|
||||
|
||||
plugin = kwargs.get('plugin', None)
|
||||
|
||||
if plugin:
|
||||
if issubclass(plugin.__class__, InvenTreePlugin):
|
||||
plugin = plugin.plugin_config()
|
||||
filters['plugin'] = plugin
|
||||
|
||||
return filters
|
||||
|
||||
plugin = models.ForeignKey(
|
||||
PluginConfig,
|
||||
related_name='settings',
|
||||
null=False,
|
||||
verbose_name=_('Plugin'),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
@ -1,5 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Base Class for InvenTree plugins"""
|
||||
"""
|
||||
Base Class for InvenTree plugins
|
||||
"""
|
||||
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.utils.text import slugify
|
||||
|
||||
|
||||
class InvenTreePlugin():
|
||||
@ -7,12 +12,66 @@ class InvenTreePlugin():
|
||||
Base class for a plugin
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
# Override the plugin name for each concrete plugin instance
|
||||
PLUGIN_NAME = ''
|
||||
|
||||
PLUGIN_SLUG = None
|
||||
|
||||
PLUGIN_TITLE = None
|
||||
|
||||
def plugin_name(self):
|
||||
"""get plugin name"""
|
||||
"""
|
||||
Return the name of this plugin plugin
|
||||
"""
|
||||
return self.PLUGIN_NAME
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
def plugin_slug(self):
|
||||
|
||||
slug = getattr(self, 'PLUGIN_SLUG', None)
|
||||
|
||||
if slug is None:
|
||||
slug = self.plugin_name()
|
||||
|
||||
return slugify(slug.lower())
|
||||
|
||||
def plugin_title(self):
|
||||
|
||||
if self.PLUGIN_TITLE:
|
||||
return self.PLUGIN_TITLE
|
||||
else:
|
||||
return self.plugin_name()
|
||||
|
||||
def plugin_config(self, raise_error=False):
|
||||
"""
|
||||
Return the PluginConfig object associated with this plugin
|
||||
"""
|
||||
|
||||
try:
|
||||
import plugin.models
|
||||
|
||||
cfg, _ = plugin.models.PluginConfig.objects.get_or_create(
|
||||
key=self.plugin_slug(),
|
||||
name=self.plugin_name(),
|
||||
)
|
||||
except (OperationalError, ProgrammingError) as error:
|
||||
cfg = None
|
||||
|
||||
if raise_error:
|
||||
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
|
||||
|
@ -1,7 +1,10 @@
|
||||
"""
|
||||
registry for plugins
|
||||
holds the class and the object that contains all code to maintain plugin states
|
||||
Registry for loading and managing multiple plugins at run-time
|
||||
|
||||
- Holds the class and the object that contains all code to maintain plugin states
|
||||
- Manages setup and teardown of plugin class instances
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import pathlib
|
||||
import logging
|
||||
@ -33,7 +36,11 @@ from .helpers import get_plugin_error, IntegrationPluginError
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class Plugins:
|
||||
class PluginsRegistry:
|
||||
"""
|
||||
The PluginsRegistry class
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# plugin registry
|
||||
self.plugins = {}
|
||||
@ -49,29 +56,39 @@ class Plugins:
|
||||
|
||||
# integration specific
|
||||
self.installed_apps = [] # Holds all added plugin_paths
|
||||
|
||||
# mixins
|
||||
self.mixins_globalsettings = {}
|
||||
self.mixins_settings = {}
|
||||
|
||||
# region public plugin functions
|
||||
def load_plugins(self):
|
||||
"""load and activate all IntegrationPlugins"""
|
||||
"""
|
||||
Load and activate all IntegrationPlugins
|
||||
"""
|
||||
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
# Plugins not enabled, do nothing
|
||||
return
|
||||
|
||||
from plugin.helpers import log_plugin_error
|
||||
|
||||
logger.info('Start loading plugins')
|
||||
# set maintanace mode
|
||||
|
||||
# Set maintanace mode
|
||||
_maintenance = bool(get_maintenance_mode())
|
||||
if not _maintenance:
|
||||
set_maintenance_mode(True)
|
||||
|
||||
registered_sucessfull = False
|
||||
registered_successful = False
|
||||
blocked_plugin = None
|
||||
retry_counter = settings.PLUGIN_RETRY
|
||||
while not registered_sucessfull:
|
||||
|
||||
while not registered_successful:
|
||||
try:
|
||||
# we are using the db so for migrations etc we need to try this block
|
||||
# We are using the db so for migrations etc we need to try this block
|
||||
self._init_plugins(blocked_plugin)
|
||||
self._activate_plugins()
|
||||
registered_sucessfull = True
|
||||
registered_successful = True
|
||||
except (OperationalError, ProgrammingError):
|
||||
# Exception if the database has not been migrated yet
|
||||
logger.info('Database not accessible while loading plugins')
|
||||
@ -81,13 +98,14 @@ class Plugins:
|
||||
log_plugin_error({error.path: error.message}, 'load')
|
||||
blocked_plugin = error.path # we will not try to load this app again
|
||||
|
||||
# init apps without any integration plugins
|
||||
# Initialize apps without any integration plugins
|
||||
self._clean_registry()
|
||||
self._clean_installed_apps()
|
||||
self._activate_plugins(force_reload=True)
|
||||
|
||||
# we do not want to end in an endless loop
|
||||
# We do not want to end in an endless loop
|
||||
retry_counter -= 1
|
||||
|
||||
if retry_counter <= 0:
|
||||
if settings.PLUGIN_TESTING:
|
||||
print('[PLUGIN] Max retries, breaking loading')
|
||||
@ -98,15 +116,24 @@ class Plugins:
|
||||
|
||||
# now the loading will re-start up with init
|
||||
|
||||
# remove maintenance
|
||||
# Remove maintenance mode
|
||||
if not _maintenance:
|
||||
set_maintenance_mode(False)
|
||||
|
||||
logger.info('Finished loading plugins')
|
||||
|
||||
def unload_plugins(self):
|
||||
"""unload and deactivate all IntegrationPlugins"""
|
||||
"""
|
||||
Unload and deactivate all IntegrationPlugins
|
||||
"""
|
||||
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
# Plugins not enabled, do nothing
|
||||
return
|
||||
|
||||
logger.info('Start unloading plugins')
|
||||
# set maintanace mode
|
||||
|
||||
# Set maintanace mode
|
||||
_maintenance = bool(get_maintenance_mode())
|
||||
if not _maintenance:
|
||||
set_maintenance_mode(True)
|
||||
@ -123,21 +150,31 @@ class Plugins:
|
||||
logger.info('Finished unloading plugins')
|
||||
|
||||
def reload_plugins(self):
|
||||
"""safely reload IntegrationPlugins"""
|
||||
# do not reload whe currently loading
|
||||
"""
|
||||
Safely reload IntegrationPlugins
|
||||
"""
|
||||
|
||||
# Do not reload whe currently loading
|
||||
if self.is_loading:
|
||||
return
|
||||
|
||||
logger.info('Start reloading plugins')
|
||||
|
||||
with maintenance_mode_on():
|
||||
self.unload_plugins()
|
||||
self.load_plugins()
|
||||
logger.info('Finished reloading plugins')
|
||||
# endregion
|
||||
|
||||
# region general plugin managment mechanisms
|
||||
logger.info('Finished reloading plugins')
|
||||
|
||||
def collect_plugins(self):
|
||||
"""collect integration plugins from all possible ways of loading"""
|
||||
"""
|
||||
Collect integration plugins from all possible ways of loading
|
||||
"""
|
||||
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
# Plugins not enabled, do nothing
|
||||
return
|
||||
|
||||
self.plugin_modules = [] # clear
|
||||
|
||||
# Collect plugins from paths
|
||||
@ -146,35 +183,41 @@ class Plugins:
|
||||
if modules:
|
||||
[self.plugin_modules.append(item) for item in modules]
|
||||
|
||||
# check if not running in testing mode and apps should be loaded from hooks
|
||||
# Check if not running in testing mode and apps should be loaded from hooks
|
||||
if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP):
|
||||
# Collect plugins from setup entry points
|
||||
for entry in metadata.entry_points().get('inventree_plugins', []):
|
||||
plugin = entry.load()
|
||||
plugin.is_package = True
|
||||
self.plugin_modules.append(plugin)
|
||||
try:
|
||||
plugin = entry.load()
|
||||
plugin.is_package = True
|
||||
self.plugin_modules.append(plugin)
|
||||
except Exception as error:
|
||||
get_plugin_error(error, do_log=True, log_name='discovery')
|
||||
|
||||
# Log collected plugins
|
||||
logger.info(f'Collected {len(self.plugin_modules)} plugins!')
|
||||
logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
|
||||
|
||||
def _init_plugins(self, disabled=None):
|
||||
"""initialise all found plugins
|
||||
"""
|
||||
Initialise all found plugins
|
||||
|
||||
:param disabled: loading path of disabled app, defaults to None
|
||||
:type disabled: str, optional
|
||||
:raises error: IntegrationPluginError
|
||||
"""
|
||||
|
||||
from plugin.models import PluginConfig
|
||||
|
||||
logger.info('Starting plugin initialisation')
|
||||
|
||||
# Initialize integration plugins
|
||||
for plugin in self.plugin_modules:
|
||||
# check if package
|
||||
# Check if package
|
||||
was_packaged = getattr(plugin, 'is_package', False)
|
||||
|
||||
# check if activated
|
||||
# these checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
|
||||
# Check if activated
|
||||
# These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
|
||||
plug_name = plugin.PLUGIN_NAME
|
||||
plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
|
||||
plug_key = slugify(plug_key) # keys are slugs!
|
||||
@ -186,23 +229,23 @@ class Plugins:
|
||||
raise error
|
||||
plugin_db_setting = None
|
||||
|
||||
# always activate if testing
|
||||
# Always activate if testing
|
||||
if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):
|
||||
# check if the plugin was blocked -> threw an error
|
||||
# Check if the plugin was blocked -> threw an error
|
||||
if disabled:
|
||||
# option1: package, option2: file-based
|
||||
if (plugin.__name__ == disabled) or (plugin.__module__ == disabled):
|
||||
# errors are bad so disable the plugin in the database
|
||||
# Errors are bad so disable the plugin in the database
|
||||
if not settings.PLUGIN_TESTING:
|
||||
plugin_db_setting.active = False
|
||||
# TODO save the error to the plugin
|
||||
plugin_db_setting.save(no_reload=True)
|
||||
|
||||
# add to inactive plugins so it shows up in the ui
|
||||
# Add to inactive plugins so it shows up in the ui
|
||||
self.plugins_inactive[plug_key] = plugin_db_setting
|
||||
continue # continue -> the plugin is not loaded
|
||||
|
||||
# init package
|
||||
# Initialize package
|
||||
# now we can be sure that an admin has activated the plugin
|
||||
# TODO check more stuff -> as of Nov 2021 there are not many checks in place
|
||||
# but we could enhance those to check signatures, run the plugin against a whitelist etc.
|
||||
@ -225,7 +268,8 @@ class Plugins:
|
||||
self.plugins_inactive[plug_key] = plugin_db_setting
|
||||
|
||||
def _activate_plugins(self, force_reload=False):
|
||||
"""run integration functions for all plugins
|
||||
"""
|
||||
Run integration functions for all plugins
|
||||
|
||||
:param force_reload: force reload base apps, defaults to False
|
||||
:type force_reload: bool, optional
|
||||
@ -234,49 +278,91 @@ class Plugins:
|
||||
plugins = self.plugins.items()
|
||||
logger.info(f'Found {len(plugins)} active plugins')
|
||||
|
||||
self.activate_integration_globalsettings(plugins)
|
||||
self.activate_integration_settings(plugins)
|
||||
self.activate_integration_schedule(plugins)
|
||||
self.activate_integration_app(plugins, force_reload=force_reload)
|
||||
|
||||
def _deactivate_plugins(self):
|
||||
"""run integration deactivation functions for all plugins"""
|
||||
"""
|
||||
Run integration deactivation functions for all plugins
|
||||
"""
|
||||
|
||||
self.deactivate_integration_app()
|
||||
self.deactivate_integration_globalsettings()
|
||||
# endregion
|
||||
self.deactivate_integration_schedule()
|
||||
self.deactivate_integration_settings()
|
||||
|
||||
# region specific integrations
|
||||
# region integration_globalsettings
|
||||
def activate_integration_globalsettings(self, plugins):
|
||||
from common.models import InvenTreeSetting
|
||||
def activate_integration_settings(self, plugins):
|
||||
|
||||
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'):
|
||||
logger.info('Registering IntegrationPlugin global settings')
|
||||
for slug, plugin in plugins:
|
||||
if plugin.mixin_enabled('settings'):
|
||||
plugin_setting = plugin.globalsettingspatterns
|
||||
self.mixins_globalsettings[slug] = plugin_setting
|
||||
logger.info('Activating plugin settings')
|
||||
|
||||
# Add to settings dir
|
||||
InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
|
||||
self.mixins_settings = {}
|
||||
|
||||
def deactivate_integration_globalsettings(self):
|
||||
from common.models import InvenTreeSetting
|
||||
for slug, plugin in plugins:
|
||||
if plugin.mixin_enabled('settings'):
|
||||
plugin_setting = plugin.settings
|
||||
self.mixins_settings[slug] = plugin_setting
|
||||
|
||||
def deactivate_integration_settings(self):
|
||||
|
||||
# collect all settings
|
||||
plugin_settings = {}
|
||||
for _, plugin_setting in self.mixins_globalsettings.items():
|
||||
|
||||
for _, plugin_setting in self.mixins_settings.items():
|
||||
plugin_settings.update(plugin_setting)
|
||||
|
||||
# remove settings
|
||||
for setting in plugin_settings:
|
||||
InvenTreeSetting.GLOBAL_SETTINGS.pop(setting)
|
||||
|
||||
# clear cache
|
||||
self.mixins_globalsettings = {}
|
||||
# endregion
|
||||
self.mixins_settings = {}
|
||||
|
||||
def activate_integration_schedule(self, plugins):
|
||||
|
||||
logger.info('Activating plugin tasks')
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
# List of tasks we have activated
|
||||
task_keys = []
|
||||
|
||||
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SCHEDULE'):
|
||||
|
||||
for slug, plugin in plugins:
|
||||
|
||||
if plugin.mixin_enabled('schedule'):
|
||||
config = plugin.plugin_config()
|
||||
|
||||
# Only active tasks for plugins which are enabled
|
||||
if config and config.active:
|
||||
plugin.register_tasks()
|
||||
task_keys += plugin.get_task_names()
|
||||
|
||||
if len(task_keys) > 0:
|
||||
logger.info(f"Activated {len(task_keys)} scheduled tasks")
|
||||
|
||||
# Remove any scheduled tasks which do not match
|
||||
# This stops 'old' plugin tasks from accumulating
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
|
||||
scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.")
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
for task in scheduled_plugin_tasks:
|
||||
if task.name not in task_keys:
|
||||
task.delete()
|
||||
deleted_count += 1
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"Removed {deleted_count} old scheduled tasks")
|
||||
except (ProgrammingError, OperationalError):
|
||||
# Database might not yet be ready
|
||||
logger.warning("activate_integration_schedule failed, database not ready")
|
||||
|
||||
def deactivate_integration_schedule(self):
|
||||
pass
|
||||
|
||||
# region integration_app
|
||||
def activate_integration_app(self, plugins, force_reload=False):
|
||||
"""activate AppMixin plugins - add custom apps and reload
|
||||
"""
|
||||
Activate AppMixin plugins - add custom apps and reload
|
||||
|
||||
:param plugins: list of IntegrationPlugins that should be installed
|
||||
:type plugins: dict
|
||||
@ -360,7 +446,10 @@ class Plugins:
|
||||
return plugin_path
|
||||
|
||||
def deactivate_integration_app(self):
|
||||
"""deactivate integration app - some magic required"""
|
||||
"""
|
||||
Deactivate integration app - some magic required
|
||||
"""
|
||||
|
||||
# unregister models from admin
|
||||
for plugin_path in self.installed_apps:
|
||||
models = [] # the modelrefs need to be collected as poping an item in a iter is not welcomed
|
||||
@ -448,8 +537,6 @@ class Plugins:
|
||||
return True, []
|
||||
except Exception as error:
|
||||
get_plugin_error(error, do_raise=True)
|
||||
# endregion
|
||||
# endregion
|
||||
|
||||
|
||||
plugins = Plugins()
|
||||
plugin_registry = PluginsRegistry()
|
||||
|
32
InvenTree/plugin/samples/integration/api_caller.py
Normal file
32
InvenTree/plugin/samples/integration/api_caller.py
Normal 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')
|
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))
|
@ -44,6 +44,27 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'API_KEY': {
|
||||
'name': _('API Key'),
|
||||
'description': _('Key required for accessing external API'),
|
||||
},
|
||||
'NUMERICAL_SETTING': {
|
||||
'name': _('Numerical'),
|
||||
'description': _('A numerical setting'),
|
||||
'validator': int,
|
||||
'default': 123,
|
||||
},
|
||||
'CHOICE_SETTING': {
|
||||
'name': _("Choice Setting"),
|
||||
'description': _('A setting with multiple choices'),
|
||||
'choices': [
|
||||
('A', 'Anaconda'),
|
||||
('B', 'Bat'),
|
||||
('C', 'Cat'),
|
||||
('D', 'Dog'),
|
||||
],
|
||||
'default': 'A',
|
||||
},
|
||||
}
|
||||
|
||||
NAVIGATION = [
|
||||
|
37
InvenTree/plugin/samples/integration/scheduled_task.py
Normal file
37
InvenTree/plugin/samples/integration/scheduled_task.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""
|
||||
Sample plugin which supports task scheduling
|
||||
"""
|
||||
|
||||
from plugin import IntegrationPluginBase
|
||||
from plugin.mixins import ScheduleMixin
|
||||
|
||||
|
||||
# Define some simple tasks to perform
|
||||
def print_hello():
|
||||
print("Hello")
|
||||
|
||||
|
||||
def print_world():
|
||||
print("World")
|
||||
|
||||
|
||||
class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase):
|
||||
"""
|
||||
A sample plugin which provides support for scheduled tasks
|
||||
"""
|
||||
|
||||
PLUGIN_NAME = "ScheduledTasksPlugin"
|
||||
PLUGIN_SLUG = "schedule"
|
||||
PLUGIN_TITLE = "Scheduled Tasks"
|
||||
|
||||
SCHEDULED_TASKS = {
|
||||
'hello': {
|
||||
'func': 'plugin.samples.integration.scheduled_task.print_hello',
|
||||
'schedule': 'I',
|
||||
'minutes': 45,
|
||||
},
|
||||
'world': {
|
||||
'func': 'plugin.samples.integration.scheduled_task.print_hello',
|
||||
'schedule': 'H',
|
||||
},
|
||||
}
|
21
InvenTree/plugin/samples/integration/test_api_caller.py
Normal file
21
InvenTree/plugin/samples/integration/test_api_caller.py
Normal 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,)
|
@ -1,5 +1,5 @@
|
||||
"""
|
||||
JSON serializers for Stock app
|
||||
JSON serializers for plugin app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
@ -11,14 +11,18 @@ import subprocess
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils import timezone
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from plugin.models import PluginConfig
|
||||
from plugin.models import PluginConfig, PluginSetting
|
||||
from InvenTree.config import get_plugin_file
|
||||
from common.serializers import SettingsSerializer
|
||||
|
||||
|
||||
class PluginConfigSerializer(serializers.ModelSerializer):
|
||||
""" Serializer for a PluginConfig:
|
||||
"""
|
||||
Serializer for a PluginConfig:
|
||||
"""
|
||||
|
||||
meta = serializers.DictField(read_only=True)
|
||||
@ -71,7 +75,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
|
||||
if not data.get('confirm'):
|
||||
raise ValidationError({'confirm': _('Installation not confirmed')})
|
||||
if (not data.get('url')) and (not data.get('packagename')):
|
||||
msg = _('Either packagenmae of url must be provided')
|
||||
msg = _('Either packagename of URL must be provided')
|
||||
raise ValidationError({'url': msg, 'packagename': msg})
|
||||
|
||||
return data
|
||||
@ -83,37 +87,64 @@ class PluginConfigInstallSerializer(serializers.Serializer):
|
||||
url = data.get('url', '')
|
||||
|
||||
# build up the command
|
||||
command = 'python -m pip install'.split()
|
||||
install_name = []
|
||||
|
||||
if url:
|
||||
# use custom registration / VCS
|
||||
if True in [identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn', ]]:
|
||||
# using a VCS provider
|
||||
if packagename:
|
||||
command.append(f'{packagename}@{url}')
|
||||
install_name.append(f'{packagename}@{url}')
|
||||
else:
|
||||
command.append(url)
|
||||
install_name.append(url)
|
||||
else:
|
||||
# using a custom package repositories
|
||||
command.append('-i')
|
||||
command.append(url)
|
||||
command.append(packagename)
|
||||
install_name.append('-i')
|
||||
install_name.append(url)
|
||||
install_name.append(packagename)
|
||||
|
||||
elif packagename:
|
||||
# use pypi
|
||||
command.append(packagename)
|
||||
install_name.append(packagename)
|
||||
|
||||
command = 'python -m pip install'.split()
|
||||
command.extend(install_name)
|
||||
ret = {'command': ' '.join(command)}
|
||||
success = False
|
||||
# execute pypi
|
||||
try:
|
||||
result = subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR))
|
||||
ret['result'] = str(result, 'utf-8')
|
||||
ret['success'] = True
|
||||
success = True
|
||||
except subprocess.CalledProcessError as error:
|
||||
ret['result'] = str(error.output, 'utf-8')
|
||||
ret['error'] = True
|
||||
|
||||
# register plugins
|
||||
# TODO
|
||||
# save plugin to plugin_file if installed successfull
|
||||
if success:
|
||||
with open(get_plugin_file(), "a") as plugin_file:
|
||||
plugin_file.write(f'{" ".join(install_name)} # Installed {timezone.now()} by {str(self.context["request"].user)}\n')
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class PluginSettingSerializer(SettingsSerializer):
|
||||
"""
|
||||
Serializer for the PluginSetting model
|
||||
"""
|
||||
|
||||
plugin = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PluginSetting
|
||||
fields = [
|
||||
'pk',
|
||||
'key',
|
||||
'value',
|
||||
'name',
|
||||
'description',
|
||||
'type',
|
||||
'choices',
|
||||
'plugin',
|
||||
]
|
||||
|
@ -7,7 +7,7 @@ from django import template
|
||||
from django.urls import reverse
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from plugin import plugin_reg
|
||||
from plugin import plugin_registry
|
||||
|
||||
|
||||
register = template.Library()
|
||||
@ -16,19 +16,19 @@ register = template.Library()
|
||||
@register.simple_tag()
|
||||
def plugin_list(*args, **kwargs):
|
||||
""" Return a list of all installed integration plugins """
|
||||
return plugin_reg.plugins
|
||||
return plugin_registry.plugins
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inactive_plugin_list(*args, **kwargs):
|
||||
""" Return a list of all inactive integration plugins """
|
||||
return plugin_reg.plugins_inactive
|
||||
return plugin_registry.plugins_inactive
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def plugin_globalsettings(plugin, *args, **kwargs):
|
||||
""" Return a list of all global settings for a plugin """
|
||||
return plugin_reg.mixins_globalsettings.get(plugin)
|
||||
def plugin_settings(plugin, *args, **kwargs):
|
||||
""" Return a list of all custom settings for a plugin """
|
||||
return plugin_registry.mixins_settings.get(plugin)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
@ -57,4 +57,4 @@ def safe_url(view_name, *args, **kwargs):
|
||||
@register.simple_tag()
|
||||
def plugin_errors(*args, **kwargs):
|
||||
"""Return all plugin errors"""
|
||||
return plugin_reg.errors
|
||||
return plugin_registry.errors
|
||||
|
@ -8,7 +8,7 @@ from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
|
||||
class PluginDetailAPITest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Tests the plugin AP I endpoints
|
||||
Tests the plugin API endpoints
|
||||
"""
|
||||
|
||||
roles = [
|
||||
@ -19,7 +19,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
self.MSG_NO_PKG = 'Either packagenmae of url must be provided'
|
||||
self.MSG_NO_PKG = 'Either packagename of URL must be provided'
|
||||
|
||||
self.PKG_NAME = 'minimal'
|
||||
super().setUp()
|
||||
@ -64,14 +64,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
|
||||
Test the PluginConfig action commands
|
||||
"""
|
||||
from plugin.models import PluginConfig
|
||||
from plugin import plugin_reg
|
||||
from plugin import plugin_registry
|
||||
|
||||
url = reverse('admin:plugin_pluginconfig_changelist')
|
||||
fixtures = PluginConfig.objects.all()
|
||||
|
||||
# check if plugins were registered -> in some test setups the startup has no db access
|
||||
if not fixtures:
|
||||
plugin_reg.reload_plugins()
|
||||
plugin_registry.reload_plugins()
|
||||
fixtures = PluginConfig.objects.all()
|
||||
|
||||
print([str(a) for a in fixtures])
|
||||
|
@ -8,22 +8,22 @@ 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):
|
||||
MIXIN_HUMAN_NAME = 'Settings'
|
||||
MIXIN_NAME = 'settings'
|
||||
MIXIN_ENABLE_CHECK = 'has_globalsettings'
|
||||
MIXIN_ENABLE_CHECK = 'has_settings'
|
||||
|
||||
TEST_SETTINGS = {'SETTING1': {'default': '123', }}
|
||||
|
||||
@ -42,25 +42,19 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
|
||||
|
||||
def test_function(self):
|
||||
# settings variable
|
||||
self.assertEqual(self.mixin.globalsettings, self.TEST_SETTINGS)
|
||||
|
||||
# settings pattern
|
||||
target_pattern = {f'PLUGIN_{self.mixin.slug.upper()}_{key}': value for key, value in self.mixin.globalsettings.items()}
|
||||
self.assertEqual(self.mixin.globalsettingspatterns, target_pattern)
|
||||
|
||||
# no settings
|
||||
self.assertIsNone(self.mixin_nothing.globalsettings)
|
||||
self.assertIsNone(self.mixin_nothing.globalsettingspatterns)
|
||||
self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
|
||||
|
||||
# calling settings
|
||||
# not existing
|
||||
self.assertEqual(self.mixin.get_globalsetting('ABCD'), '')
|
||||
self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '')
|
||||
self.assertEqual(self.mixin.get_setting('ABCD'), '')
|
||||
self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '')
|
||||
|
||||
# right setting
|
||||
self.mixin.set_globalsetting('SETTING1', '12345', self.test_user)
|
||||
self.assertEqual(self.mixin.get_globalsetting('SETTING1'), '12345')
|
||||
self.mixin.set_setting('SETTING1', '12345', self.test_user)
|
||||
self.assertEqual(self.mixin.get_setting('SETTING1'), '12345')
|
||||
|
||||
# no setting
|
||||
self.assertEqual(self.mixin_nothing.get_globalsetting(''), '')
|
||||
self.assertEqual(self.mixin_nothing.get_setting(''), '')
|
||||
|
||||
|
||||
class UrlsMixinTest(BaseMixinDefinition, TestCase):
|
||||
@ -148,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 """
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
""" Unit tests for plugins """
|
||||
"""
|
||||
Unit tests for plugins
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
@ -6,9 +8,8 @@ import plugin.plugin
|
||||
import plugin.integration
|
||||
from plugin.samples.integration.sample import SampleIntegrationPlugin
|
||||
from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
|
||||
# from plugin.plugins import load_action_plugins, load_barcode_plugins
|
||||
import plugin.templatetags.plugin_extras as plugin_tags
|
||||
from plugin import plugin_reg
|
||||
from plugin import plugin_registry
|
||||
|
||||
|
||||
class InvenTreePluginTests(TestCase):
|
||||
@ -57,17 +58,17 @@ class PluginTagTests(TestCase):
|
||||
|
||||
def test_tag_plugin_list(self):
|
||||
"""test that all plugins are listed"""
|
||||
self.assertEqual(plugin_tags.plugin_list(), plugin_reg.plugins)
|
||||
self.assertEqual(plugin_tags.plugin_list(), plugin_registry.plugins)
|
||||
|
||||
def test_tag_incative_plugin_list(self):
|
||||
"""test that all inactive plugins are listed"""
|
||||
self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_reg.plugins_inactive)
|
||||
self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_registry.plugins_inactive)
|
||||
|
||||
def test_tag_plugin_globalsettings(self):
|
||||
def test_tag_plugin_settings(self):
|
||||
"""check all plugins are listed"""
|
||||
self.assertEqual(
|
||||
plugin_tags.plugin_globalsettings(self.sample),
|
||||
plugin_reg.mixins_globalsettings.get(self.sample)
|
||||
plugin_tags.plugin_settings(self.sample),
|
||||
plugin_registry.mixins_settings.get(self.sample)
|
||||
)
|
||||
|
||||
def test_tag_mixin_enabled(self):
|
||||
@ -89,4 +90,4 @@ class PluginTagTests(TestCase):
|
||||
|
||||
def test_tag_plugin_errors(self):
|
||||
"""test that all errors are listed"""
|
||||
self.assertEqual(plugin_tags.plugin_errors(), plugin_reg.errors)
|
||||
self.assertEqual(plugin_tags.plugin_errors(), plugin_registry.errors)
|
||||
|
@ -1,18 +1,24 @@
|
||||
"""
|
||||
URL lookup for plugin app
|
||||
"""
|
||||
|
||||
from django.conf.urls import url, include
|
||||
|
||||
from plugin import plugin_reg
|
||||
from plugin import plugin_registry
|
||||
|
||||
|
||||
PLUGIN_BASE = 'plugin' # Constant for links
|
||||
|
||||
|
||||
def get_plugin_urls():
|
||||
"""returns a urlpattern that can be integrated into the global urls"""
|
||||
"""
|
||||
Returns a urlpattern that can be integrated into the global urls
|
||||
"""
|
||||
|
||||
urls = []
|
||||
for plugin in plugin_reg.plugins.values():
|
||||
|
||||
for plugin in plugin_registry.plugins.values():
|
||||
if plugin.mixin_enabled('urls'):
|
||||
urls.append(plugin.urlpatterns)
|
||||
|
||||
return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -13,19 +13,19 @@
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-user-shield" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-user-lock" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-at" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" icon='fa-key' %}
|
||||
<tr>
|
||||
<th><h5>{% trans 'Signup' %}</h5></th>
|
||||
<td colspan='4'></td>
|
||||
</tr>
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-info-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-info-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-info-circle" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-user-plus" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-at" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-user-lock" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-key" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" icon="fa-users" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
@ -5,12 +5,12 @@
|
||||
<h4>{% trans "Settings" %}</h4>
|
||||
</div>
|
||||
|
||||
{% plugin_globalsettings plugin_key as plugin_settings %}
|
||||
{% plugin_settings plugin_key as plugin_settings %}
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% for setting in plugin_settings %}
|
||||
{% include "InvenTree/settings/setting.html" with key=setting%}
|
||||
{% include "InvenTree/settings/setting.html" with key=setting plugin=plugin %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
@ -19,17 +19,18 @@
|
||||
<div class='table-responsive'>
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_GLOBALSETTING"%}
|
||||
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%}
|
||||
{% 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" %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Plugin list" %}</h4>
|
||||
<h4>{% trans "Plugins" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% url 'admin:plugin_pluginconfig_changelist' as url %}
|
||||
@ -70,7 +71,7 @@
|
||||
{% if mixin_list %}
|
||||
{% for mixin in mixin_list %}
|
||||
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
|
||||
<span class='badge bg-dark badge-right'>{{ mixin.human_name }}</span>
|
||||
<span class='badge bg-dark badge-right rounded-pill'>{{ mixin.human_name }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
@ -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,10 +12,10 @@
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %}
|
||||
{% 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" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
@ -1,10 +1,12 @@
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
|
||||
{% if user_setting %}
|
||||
{% setting_object key user=request.user as setting %}
|
||||
{% if plugin %}
|
||||
{% setting_object key plugin=plugin as setting %}
|
||||
{% elif user_setting %}
|
||||
{% setting_object key user=request.user as setting %}
|
||||
{% else %}
|
||||
{% setting_object key as setting %}
|
||||
{% setting_object key as setting %}
|
||||
{% endif %}
|
||||
|
||||
<tr>
|
||||
@ -13,7 +15,7 @@
|
||||
<span class='fas {{ icon }}'></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><strong>{% trans setting.name %}</strong></td>
|
||||
<td><strong>{{ setting.name }}</strong></td>
|
||||
<td>
|
||||
{% if setting.is_bool %}
|
||||
<div class='form-check form-switch'>
|
||||
@ -32,11 +34,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<td>
|
||||
{% trans setting.description %}
|
||||
{{ setting.description }}
|
||||
</td>
|
||||
<td>
|
||||
<div class='btn-group float-right'>
|
||||
<button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if user_setting %}user='{{request.user.id}}'{% endif %}>
|
||||
<button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if plugin %}plugin='{{ plugin.pk }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}>
|
||||
<span class='fas fa-edit icon-green'></span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -39,14 +39,17 @@
|
||||
{% include "InvenTree/settings/build.html" %}
|
||||
{% include "InvenTree/settings/po.html" %}
|
||||
{% include "InvenTree/settings/so.html" %}
|
||||
{% include "InvenTree/settings/plugin.html" %}
|
||||
|
||||
{% plugins_enabled as plug %}
|
||||
{% if plug %}
|
||||
{% include "InvenTree/settings/plugin.html" %}
|
||||
{% plugin_list as pl_list %}
|
||||
{% for plugin_key, plugin in pl_list.items %}
|
||||
{% if plugin.registered_mixins %}
|
||||
{% include "InvenTree/settings/plugin_settings.html" %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
@ -62,16 +65,27 @@
|
||||
$('table').find('.btn-edit-setting').click(function() {
|
||||
var setting = $(this).attr('setting');
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
var plugin = $(this).attr('plugin');
|
||||
var is_global = true;
|
||||
|
||||
if ($(this).attr('user')){
|
||||
is_global = false;
|
||||
}
|
||||
|
||||
var title = '';
|
||||
|
||||
if (plugin != null) {
|
||||
title = '{% trans "Edit Plugin Setting" %}';
|
||||
} else if (is_global) {
|
||||
title = '{% trans "Edit Global Setting" %}';
|
||||
} else {
|
||||
title = '{% trans "Edit User Setting" %}';
|
||||
}
|
||||
|
||||
editSetting(pk, {
|
||||
plugin: plugin,
|
||||
global: is_global,
|
||||
title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}',
|
||||
title: title,
|
||||
});
|
||||
});
|
||||
|
||||
@ -322,9 +336,12 @@ $("#import-part").click(function() {
|
||||
launchModalForm("{% url 'api-part-import' %}?reset", {});
|
||||
});
|
||||
|
||||
{% plugins_enabled as plug %}
|
||||
{% if plug %}
|
||||
$("#install-plugin").click(function() {
|
||||
installPlugin();
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
enableSidebar('settings');
|
||||
|
||||
|
@ -47,15 +47,17 @@
|
||||
{% trans "Sales Orders" as text %}
|
||||
{% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %}
|
||||
|
||||
{% plugins_enabled as plug %}
|
||||
{% if plug %}
|
||||
{% include "sidebar_header.html" with text="Plugin Settings" %}
|
||||
|
||||
{% include "sidebar_item.html" with label='plugin' text="Plugin" icon="fa-plug" %}
|
||||
{% include "sidebar_item.html" with label='plugin' text="Plugins" icon="fa-plug" %}
|
||||
|
||||
{% plugin_list as pl_list %}
|
||||
{% for plugin_key, plugin in pl_list.items %}
|
||||
{% if plugin.registered_mixins %}
|
||||
{% include "sidebar_item.html" with label='plugin-'|add:plugin_key text=plugin.human_name %}
|
||||
{% endif %}
|
||||
{% if plugin.registered_mixins %}
|
||||
{% include "sidebar_item.html" with label='plugin-'|add:plugin_key text=plugin.human_name %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
@ -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'>
|
||||
|
@ -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>
|
||||
|
@ -28,9 +28,13 @@ function editSetting(pk, options={}) {
|
||||
// Is this a global setting or a user setting?
|
||||
var global = options.global || false;
|
||||
|
||||
var plugin = options.plugin;
|
||||
|
||||
var url = '';
|
||||
|
||||
if (global) {
|
||||
if (plugin) {
|
||||
url = `/api/plugin/settings/${pk}/`;
|
||||
} else if (global) {
|
||||
url = `/api/settings/global/${pk}/`;
|
||||
} else {
|
||||
url = `/api/settings/user/${pk}/`;
|
||||
|
@ -20,6 +20,7 @@
|
||||
|
||||
/* exported
|
||||
allocateStockToBuild,
|
||||
completeBuildOrder,
|
||||
editBuildOrder,
|
||||
loadAllocationTable,
|
||||
loadBuildOrderAllocationTable,
|
||||
@ -120,6 +121,60 @@ function newBuildOrder(options={}) {
|
||||
}
|
||||
|
||||
|
||||
/* Construct a form to "complete" (finish) a build order */
|
||||
function completeBuildOrder(build_id, options={}) {
|
||||
|
||||
var url = `/api/build/${build_id}/finish/`;
|
||||
|
||||
var fields = {
|
||||
accept_unallocated: {},
|
||||
accept_incomplete: {},
|
||||
};
|
||||
|
||||
var html = '';
|
||||
|
||||
if (options.allocated && options.completed) {
|
||||
html += `
|
||||
<div class='alert alert-block alert-success'>
|
||||
{% trans "Build order is ready to be completed" %}
|
||||
</div>`;
|
||||
} else {
|
||||
html += `
|
||||
<div class='alert alert-block alert-danger'>
|
||||
<strong>{% trans "Build Order is incomplete" %}</strong>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!options.allocated) {
|
||||
html += `<div class='alert alert-block alert-warning'>{% trans "Required stock has not been fully allocated" %}</div>`;
|
||||
}
|
||||
|
||||
if (!options.completed) {
|
||||
html += `<div class='alert alert-block alert-warning'>{% trans "Required build quantity has not been completed" %}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide particular fields if they are not required
|
||||
|
||||
if (options.allocated) {
|
||||
delete fields.accept_unallocated;
|
||||
}
|
||||
|
||||
if (options.completed) {
|
||||
delete fields.accept_incomplete;
|
||||
}
|
||||
|
||||
constructForm(url, {
|
||||
fields: fields,
|
||||
reload: true,
|
||||
confirm: true,
|
||||
method: 'POST',
|
||||
title: '{% trans "Complete Build Order" %}',
|
||||
preFormContent: html,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Construct a set of output buttons for a particular build output
|
||||
*/
|
||||
|
@ -305,7 +305,16 @@ function setupFilterList(tableKey, table, target) {
|
||||
var title = getFilterTitle(tableKey, key);
|
||||
var description = getFilterDescription(tableKey, key);
|
||||
|
||||
element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
|
||||
var filter_tag = `
|
||||
<div title='${description}' class='filter-tag'>
|
||||
${title} = ${value}
|
||||
<span ${tag}='${key}' class='close' style='color: #F55;'>
|
||||
<span aria-hidden='true'><strong>×</strong></span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
element.append(filter_tag);
|
||||
}
|
||||
|
||||
// Callback for reloading the table
|
||||
|
@ -555,13 +555,18 @@ function renderErrorMessage(xhr) {
|
||||
}
|
||||
|
||||
|
||||
function showAlertDialog(title, content) {
|
||||
function showAlertDialog(title, content, options={}) {
|
||||
/* Display a modal dialog message box.
|
||||
*
|
||||
* title - Title text
|
||||
* content - HTML content of the dialog window
|
||||
*/
|
||||
|
||||
if (options.alert_style) {
|
||||
// Wrap content in an alert block
|
||||
content = `<div class='alert alert-block alert-${options.alert_style}'>${content}</div>`;
|
||||
}
|
||||
|
||||
|
||||
var modal = createNewModal({
|
||||
title: title,
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -371,7 +371,12 @@ function customGroupSorter(sortName, sortOrder, sortData) {
|
||||
return `${pageNumber} {% trans "rows per page" %}`;
|
||||
},
|
||||
formatShowingRows: function(pageFrom, pageTo, totalRows) {
|
||||
return `{% trans "Showing" %} ${pageFrom} {% trans "to" %} ${pageTo} {% trans "of" %} ${totalRows} {% trans "rows" %}`;
|
||||
|
||||
if (totalRows === undefined || totalRows === NaN) {
|
||||
return '{% trans "Showing all rows" %}';
|
||||
} else {
|
||||
return `{% trans "Showing" %} ${pageFrom} {% trans "to" %} ${pageTo} {% trans "of" %} ${totalRows} {% trans "rows" %}`;
|
||||
}
|
||||
},
|
||||
formatSearch: function() {
|
||||
return '{% trans "Search" %}';
|
||||
|
@ -119,7 +119,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 %}
|
||||
|
@ -34,6 +34,18 @@
|
||||
<td>{% trans "Server is deployed using docker" %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-plug'></span></td>
|
||||
<td>{% trans "Plugin Support" %}</td>
|
||||
<td>
|
||||
{% plugins_enabled as p_en %}
|
||||
{% if p_en %}
|
||||
<span class='badge rounded-pill bg-success'>{% trans "Plugin support enabled" %}</span>
|
||||
{% else %}
|
||||
<span class='badge rounded-pill bg-warning'>{% trans "Plugin support disabled" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if user.is_staff %}
|
||||
<tr>
|
||||
<td><span class='fas fa-server'></span></td>
|
||||
|
@ -76,7 +76,8 @@ class RuleSet(models.Model):
|
||||
'otp_totp_totpdevice',
|
||||
'otp_static_statictoken',
|
||||
'otp_static_staticdevice',
|
||||
'plugin_pluginconfig'
|
||||
'plugin_pluginconfig',
|
||||
'plugin_pluginsetting',
|
||||
],
|
||||
'part_category': [
|
||||
'part_partcategory',
|
||||
@ -155,6 +156,8 @@ class RuleSet(models.Model):
|
||||
'common_colortheme',
|
||||
'common_inventreesetting',
|
||||
'common_inventreeusersetting',
|
||||
'common_webhookendpoint',
|
||||
'common_webhookmessage',
|
||||
'common_notificationentry',
|
||||
'common_notificationmessage',
|
||||
'company_contact',
|
||||
|
@ -8,7 +8,7 @@
|
||||
[](https://crowdin.com/project/inventree)
|
||||

|
||||
|
||||
InvenTree is an open-source Inventory Management System which provides powerful low-level stock control and part tracking. The core of the InvenTree system is a Python/Django database backend which provides an admin interface (web-based) and a JSON API for interaction with external interfaces and applications.
|
||||
InvenTree is an open-source Inventory Management System which provides powerful low-level stock control and part tracking. The core of the InvenTree system is a Python/Django database backend which provides an admin interface (web-based) and a REST API for interaction with external interfaces and applications.
|
||||
|
||||
InvenTree is designed to be lightweight and easy to use for SME or hobbyist applications, where many existing stock management solutions are bloated and cumbersome to use. Updating stock is a single-action process and does not require a complex system of work orders or stock transactions.
|
||||
|
||||
|
@ -12,3 +12,6 @@ INVENTREE_DB_HOST=inventree-dev-db
|
||||
INVENTREE_DB_PORT=5432
|
||||
INVENTREE_DB_USER=pguser
|
||||
INVENTREE_DB_PASSWORD=pgpassword
|
||||
|
||||
# Enable plugins?
|
||||
INVENTREE_PLUGINS_ENABLED=False
|
||||
|
@ -14,3 +14,6 @@ INVENTREE_DB_HOST=inventree-db
|
||||
INVENTREE_DB_PORT=5432
|
||||
INVENTREE_DB_USER=pguser
|
||||
INVENTREE_DB_PASSWORD=pgpassword
|
||||
|
||||
# Enable plugins?
|
||||
INVENTREE_PLUGINS_ENABLED=False
|
||||
|
Loading…
Reference in New Issue
Block a user