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 plugin-install
This commit is contained in:
commit
220bf0db3a
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")
|
||||
|
@ -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 """
|
||||
|
||||
|
@ -555,7 +555,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():
|
||||
|
@ -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,47 @@ 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'),
|
||||
)
|
||||
|
||||
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'),
|
||||
)
|
||||
|
||||
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 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
|
||||
|
@ -224,13 +224,11 @@ src="{% static 'img/blank_image.png' %}"
|
||||
'{% trans "Build Order cannot be completed as incomplete build outputs remain" %}'
|
||||
);
|
||||
{% else %}
|
||||
launchModalForm(
|
||||
"{% url 'build-complete' build.id %}",
|
||||
{
|
||||
reload: true,
|
||||
submit_text: '{% trans "Complete Build" %}',
|
||||
}
|
||||
);
|
||||
|
||||
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 %}
|
@ -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
|
||||
)
|
||||
|
@ -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.
|
||||
|
@ -53,7 +53,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
single values (e.g. one-off settings values).
|
||||
"""
|
||||
|
||||
GLOBAL_SETTINGS = {}
|
||||
SETTINGS = {}
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -65,7 +65,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
self.key = str(self.key).upper()
|
||||
|
||||
self.clean()
|
||||
self.clean(**kwargs)
|
||||
self.validate_unique()
|
||||
|
||||
super().save()
|
||||
@ -82,6 +82,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
results = cls.objects.all()
|
||||
|
||||
# Optionally filter by user
|
||||
if user is not None:
|
||||
results = results.filter(user=user)
|
||||
|
||||
@ -93,13 +94,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
|
||||
@ -123,98 +124,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...)
|
||||
@ -237,17 +232,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
|
||||
@ -259,21 +277,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):
|
||||
"""
|
||||
@ -283,18 +286,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):
|
||||
@ -357,7 +361,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.
|
||||
@ -365,25 +369,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.
|
||||
@ -395,7 +390,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"
|
||||
@ -406,7 +401,7 @@ class BaseInvenTreeSetting(models.Model):
|
||||
})
|
||||
|
||||
# Integer validator
|
||||
if self.is_int():
|
||||
if validator is int:
|
||||
|
||||
try:
|
||||
# Coerce into an integer value
|
||||
@ -459,12 +454,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)
|
||||
|
||||
@ -477,15 +472,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:
|
||||
@ -504,12 +499,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)
|
||||
|
||||
@ -541,21 +536,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()]]
|
||||
|
||||
|
||||
@ -577,7 +571,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:
|
||||
@ -595,7 +589,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
The keys must be upper-case
|
||||
"""
|
||||
|
||||
GLOBAL_SETTINGS = {
|
||||
SETTINGS = {
|
||||
|
||||
'SERVER_RESTART_REQUIRED': {
|
||||
'name': _('Restart required'),
|
||||
@ -977,13 +971,6 @@ 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'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
'requires_restart': True,
|
||||
},
|
||||
'ENABLE_PLUGINS_APP': {
|
||||
'name': _('Enable app integration'),
|
||||
'description': _('Enable plugins to add apps'),
|
||||
@ -991,6 +978,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
'requires_restart': True,
|
||||
},
|
||||
'ENABLE_PLUGINS_SCHEDULE': {
|
||||
'name': _('Enable schedule integration'),
|
||||
'description': _('Enable plugins to run scheduled tasks'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
'requires_restart': True,
|
||||
}
|
||||
}
|
||||
|
||||
class Meta:
|
||||
@ -1017,7 +1011,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)
|
||||
@ -1030,7 +1024,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'),
|
||||
|
@ -49,9 +49,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 +64,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)
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
# -*- 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.
|
||||
"""
|
||||
|
||||
@ -22,6 +23,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()
|
||||
|
||||
|
||||
@ -223,8 +226,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,11 @@
|
||||
from .registry import plugins as plugin_reg
|
||||
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'),
|
||||
|
@ -4,17 +4,17 @@ from __future__ import unicode_literals
|
||||
from django.apps import AppConfig
|
||||
from maintenance_mode.core import set_maintenance_mode
|
||||
|
||||
from plugin.registry import plugins
|
||||
from plugin import plugin_registry
|
||||
|
||||
|
||||
class PluginAppConfig(AppConfig):
|
||||
name = 'plugin'
|
||||
|
||||
def ready(self):
|
||||
if not plugins.is_loading:
|
||||
if not plugin_registry.is_loading:
|
||||
# this is the first startup
|
||||
plugins.collect_plugins()
|
||||
plugins.load_plugins()
|
||||
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
|
||||
|
@ -2,11 +2,18 @@
|
||||
Plugin mixin classes
|
||||
"""
|
||||
|
||||
from django.conf.urls import url, include
|
||||
import logging
|
||||
|
||||
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 +24,160 @@ 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']
|
||||
|
||||
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 UrlsMixin:
|
||||
@ -116,7 +239,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):
|
||||
|
@ -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):
|
||||
|
@ -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,13 @@
|
||||
"""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 AppMixin, SettingsMixin, ScheduleMixin, UrlsMixin, NavigationMixin
|
||||
|
||||
__all__ = [
|
||||
'AppMixin',
|
||||
'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,54 @@ 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
|
||||
|
@ -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 = {}
|
||||
@ -50,15 +57,19 @@ 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
|
||||
"""
|
||||
|
||||
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)
|
||||
@ -68,7 +79,7 @@ class Plugins:
|
||||
retry_counter = settings.PLUGIN_RETRY
|
||||
while not registered_sucessfull:
|
||||
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
|
||||
@ -81,13 +92,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 +110,20 @@ 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
|
||||
"""
|
||||
|
||||
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 +140,27 @@ 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
|
||||
"""
|
||||
|
||||
self.plugin_modules = [] # clear
|
||||
|
||||
# Collect plugins from paths
|
||||
@ -146,7 +169,7 @@ 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', []):
|
||||
@ -162,22 +185,25 @@ class 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!
|
||||
@ -189,23 +215,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.
|
||||
@ -228,7 +254,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
|
||||
@ -237,49 +264,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
|
||||
@ -363,7 +432,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
|
||||
@ -451,8 +523,6 @@ class Plugins:
|
||||
return True, []
|
||||
except Exception as error:
|
||||
get_plugin_error(error, do_raise=True)
|
||||
# endregion
|
||||
# endregion
|
||||
|
||||
|
||||
plugins = Plugins()
|
||||
plugin_registry = PluginsRegistry()
|
||||
|
@ -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 = [
|
||||
|
45
InvenTree/plugin/samples/integration/scheduled_task.py
Normal file
45
InvenTree/plugin/samples/integration/scheduled_task.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
def fail_task():
|
||||
raise ValueError("This task should fail!")
|
||||
|
||||
|
||||
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': 5,
|
||||
},
|
||||
'world': {
|
||||
'func': 'plugin.samples.integration.scheduled_task.print_hello',
|
||||
'schedule': 'H',
|
||||
},
|
||||
'failure': {
|
||||
'func': 'plugin.samples.integration.scheduled_task.fail_task',
|
||||
'schedule': 'D',
|
||||
},
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
"""
|
||||
JSON serializers for Stock app
|
||||
JSON serializers for plugin app
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
@ -15,12 +15,14 @@ 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)
|
||||
@ -73,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
|
||||
@ -125,3 +127,24 @@ class PluginConfigInstallSerializer(serializers.Serializer):
|
||||
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])
|
||||
|
@ -23,7 +23,7 @@ class BaseMixinDefinition:
|
||||
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):
|
||||
|
@ -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')))
|
||||
|
@ -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,17 @@
|
||||
<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_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 +70,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 %}
|
||||
|
@ -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="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>
|
||||
|
@ -62,16 +62,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,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -49,7 +49,7 @@
|
||||
|
||||
{% 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 %}
|
||||
|
@ -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,57 @@ 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.can_complete) {
|
||||
|
||||
} 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
|
||||
*/
|
||||
|
@ -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" %}';
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user