mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-29 20:16:44 +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
|
Custom management command to cleanup old settings that are not defined anymore
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
Cleanup old (undefined) settings in the database
|
Cleanup old (undefined) settings in the database
|
||||||
@ -12,27 +17,27 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
|
|
||||||
print("Collecting settings")
|
logger.info("Collecting settings")
|
||||||
from common.models import InvenTreeSetting, InvenTreeUserSetting
|
from common.models import InvenTreeSetting, InvenTreeUserSetting
|
||||||
|
|
||||||
# general settings
|
# general settings
|
||||||
db_settings = InvenTreeSetting.objects.all()
|
db_settings = InvenTreeSetting.objects.all()
|
||||||
model_settings = InvenTreeSetting.GLOBAL_SETTINGS
|
model_settings = InvenTreeSetting.SETTINGS
|
||||||
|
|
||||||
# check if key exist and delete if not
|
# check if key exist and delete if not
|
||||||
for setting in db_settings:
|
for setting in db_settings:
|
||||||
if setting.key not in model_settings:
|
if setting.key not in model_settings:
|
||||||
setting.delete()
|
setting.delete()
|
||||||
print(f"deleted setting '{setting.key}'")
|
logger.info(f"deleted setting '{setting.key}'")
|
||||||
|
|
||||||
# user settings
|
# user settings
|
||||||
db_settings = InvenTreeUserSetting.objects.all()
|
db_settings = InvenTreeUserSetting.objects.all()
|
||||||
model_settings = InvenTreeUserSetting.GLOBAL_SETTINGS
|
model_settings = InvenTreeUserSetting.SETTINGS
|
||||||
|
|
||||||
# check if key exist and delete if not
|
# check if key exist and delete if not
|
||||||
for setting in db_settings:
|
for setting in db_settings:
|
||||||
if setting.key not in model_settings:
|
if setting.key not in model_settings:
|
||||||
setting.delete()
|
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_SW_VERSION = "0.6.0 dev"
|
||||||
|
|
||||||
# InvenTree API version
|
# 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
|
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
|
v22 -> 2021-12-20
|
||||||
- Adds API endpoint to "merge" multiple stock items
|
- Adds API endpoint to "merge" multiple stock items
|
||||||
|
|
||||||
|
@ -18,8 +18,7 @@ from InvenTree.filters import InvenTreeOrderingFilter
|
|||||||
from InvenTree.status_codes import BuildStatus
|
from InvenTree.status_codes import BuildStatus
|
||||||
|
|
||||||
from .models import Build, BuildItem, BuildOrderAttachment
|
from .models import Build, BuildItem, BuildOrderAttachment
|
||||||
from .serializers import BuildAttachmentSerializer, BuildCompleteSerializer, BuildSerializer, BuildItemSerializer
|
import build.serializers
|
||||||
from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
|
|
||||||
from users.models import Owner
|
from users.models import Owner
|
||||||
|
|
||||||
|
|
||||||
@ -80,7 +79,7 @@ class BuildList(generics.ListCreateAPIView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = Build.objects.all()
|
queryset = Build.objects.all()
|
||||||
serializer_class = BuildSerializer
|
serializer_class = build.serializers.BuildSerializer
|
||||||
filterset_class = BuildFilter
|
filterset_class = BuildFilter
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
@ -119,7 +118,7 @@ class BuildList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = super().get_queryset().select_related('part')
|
queryset = super().get_queryset().select_related('part')
|
||||||
|
|
||||||
queryset = BuildSerializer.annotate_queryset(queryset)
|
queryset = build.serializers.BuildSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -203,7 +202,7 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
|
|||||||
""" API endpoint for detail view of a Build object """
|
""" API endpoint for detail view of a Build object """
|
||||||
|
|
||||||
queryset = Build.objects.all()
|
queryset = Build.objects.all()
|
||||||
serializer_class = BuildSerializer
|
serializer_class = build.serializers.BuildSerializer
|
||||||
|
|
||||||
|
|
||||||
class BuildUnallocate(generics.CreateAPIView):
|
class BuildUnallocate(generics.CreateAPIView):
|
||||||
@ -217,7 +216,7 @@ class BuildUnallocate(generics.CreateAPIView):
|
|||||||
|
|
||||||
queryset = Build.objects.none()
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
serializer_class = BuildUnallocationSerializer
|
serializer_class = build.serializers.BuildUnallocationSerializer
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
|
|
||||||
@ -233,14 +232,36 @@ class BuildUnallocate(generics.CreateAPIView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class BuildComplete(generics.CreateAPIView):
|
class BuildOutputComplete(generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for completing build outputs
|
API endpoint for completing build outputs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = Build.objects.none()
|
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):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
@ -269,7 +290,7 @@ class BuildAllocate(generics.CreateAPIView):
|
|||||||
|
|
||||||
queryset = Build.objects.none()
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
serializer_class = BuildAllocationSerializer
|
serializer_class = build.serializers.BuildAllocationSerializer
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
"""
|
"""
|
||||||
@ -294,7 +315,7 @@ class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = BuildItem.objects.all()
|
queryset = BuildItem.objects.all()
|
||||||
serializer_class = BuildItemSerializer
|
serializer_class = build.serializers.BuildItemSerializer
|
||||||
|
|
||||||
|
|
||||||
class BuildItemList(generics.ListCreateAPIView):
|
class BuildItemList(generics.ListCreateAPIView):
|
||||||
@ -304,7 +325,7 @@ class BuildItemList(generics.ListCreateAPIView):
|
|||||||
- POST: Create a new BuildItem object
|
- POST: Create a new BuildItem object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
serializer_class = BuildItemSerializer
|
serializer_class = build.serializers.BuildItemSerializer
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -373,7 +394,7 @@ class BuildAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = BuildOrderAttachment.objects.all()
|
queryset = BuildOrderAttachment.objects.all()
|
||||||
serializer_class = BuildAttachmentSerializer
|
serializer_class = build.serializers.BuildAttachmentSerializer
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
@ -390,7 +411,7 @@ class BuildAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = BuildOrderAttachment.objects.all()
|
queryset = BuildOrderAttachment.objects.all()
|
||||||
serializer_class = BuildAttachmentSerializer
|
serializer_class = build.serializers.BuildAttachmentSerializer
|
||||||
|
|
||||||
|
|
||||||
build_api_urls = [
|
build_api_urls = [
|
||||||
@ -410,7 +431,8 @@ build_api_urls = [
|
|||||||
# Build Detail
|
# Build Detail
|
||||||
url(r'^(?P<pk>\d+)/', include([
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
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'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||||
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
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):
|
class CancelBuildForm(HelperForm):
|
||||||
""" Form for cancelling a build """
|
""" Form for cancelling a build """
|
||||||
|
|
||||||
|
@ -555,7 +555,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
if self.incomplete_count > 0:
|
if self.incomplete_count > 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.completed < self.quantity:
|
if self.remaining > 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not self.areUntrackedPartsFullyAllocated():
|
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
|
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):
|
class BuildUnallocationSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
DRF serializer for unallocating stock from a BuildOrder
|
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" %}'
|
'{% trans "Build Order cannot be completed as incomplete build outputs remain" %}'
|
||||||
);
|
);
|
||||||
{% else %}
|
{% else %}
|
||||||
launchModalForm(
|
|
||||||
"{% url 'build-complete' build.id %}",
|
completeBuildOrder({{ build.pk }}, {
|
||||||
{
|
allocated: {% if build.areUntrackedPartsFullyAllocated %}true{% else %}false{% endif %},
|
||||||
reload: true,
|
completed: {% if build.remaining == 0 %}true{% else %}false{% endif %},
|
||||||
submit_text: '{% trans "Complete Build" %}',
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
{% 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.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):
|
def test_invalid(self):
|
||||||
"""
|
"""
|
||||||
@ -58,7 +58,7 @@ class BuildCompleteTest(BuildAPITest):
|
|||||||
|
|
||||||
# Test with an invalid build ID
|
# Test with an invalid build ID
|
||||||
self.post(
|
self.post(
|
||||||
reverse('api-build-complete', kwargs={'pk': 99999}),
|
reverse('api-build-output-complete', kwargs={'pk': 99999}),
|
||||||
{},
|
{},
|
||||||
expected_code=400
|
expected_code=400
|
||||||
)
|
)
|
||||||
|
@ -11,7 +11,6 @@ build_detail_urls = [
|
|||||||
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
||||||
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
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'^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'),
|
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):
|
class BuildDetail(InvenTreeRoleMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
Detail view of a single Build object.
|
Detail view of a single Build object.
|
||||||
|
@ -53,7 +53,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
single values (e.g. one-off settings values).
|
single values (e.g. one-off settings values).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
GLOBAL_SETTINGS = {}
|
SETTINGS = {}
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
@ -65,7 +65,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
self.key = str(self.key).upper()
|
self.key = str(self.key).upper()
|
||||||
|
|
||||||
self.clean()
|
self.clean(**kwargs)
|
||||||
self.validate_unique()
|
self.validate_unique()
|
||||||
|
|
||||||
super().save()
|
super().save()
|
||||||
@ -82,6 +82,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
results = cls.objects.all()
|
results = cls.objects.all()
|
||||||
|
|
||||||
|
# Optionally filter by user
|
||||||
if user is not None:
|
if user is not None:
|
||||||
results = results.filter(user=user)
|
results = results.filter(user=user)
|
||||||
|
|
||||||
@ -93,13 +94,13 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
settings[setting.key.upper()] = setting.value
|
settings[setting.key.upper()] = setting.value
|
||||||
|
|
||||||
# Specify any "default" values which are not in the database
|
# 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:
|
if key.upper() not in settings:
|
||||||
settings[key.upper()] = cls.get_setting_default(key)
|
settings[key.upper()] = cls.get_setting_default(key)
|
||||||
|
|
||||||
if exclude_hidden:
|
if exclude_hidden:
|
||||||
hidden = cls.GLOBAL_SETTINGS[key].get('hidden', False)
|
hidden = cls.SETTINGS[key].get('hidden', False)
|
||||||
|
|
||||||
if hidden:
|
if hidden:
|
||||||
# Remove hidden items
|
# Remove hidden items
|
||||||
@ -123,98 +124,92 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
return settings
|
return settings
|
||||||
|
|
||||||
@classmethod
|
@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.
|
Return the name of a particular setting.
|
||||||
|
|
||||||
If it does not exist, return an empty string.
|
If it does not exist, return an empty string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
key = str(key).strip().upper()
|
setting = cls.get_setting_definition(key, **kwargs)
|
||||||
|
return setting.get('name', '')
|
||||||
if key in cls.GLOBAL_SETTINGS:
|
|
||||||
setting = cls.GLOBAL_SETTINGS[key]
|
|
||||||
return setting.get('name', '')
|
|
||||||
else:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_description(cls, key):
|
def get_setting_description(cls, key, **kwargs):
|
||||||
"""
|
"""
|
||||||
Return the description for a particular setting.
|
Return the description for a particular setting.
|
||||||
|
|
||||||
If it does not exist, return an empty string.
|
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:
|
return setting.get('description', '')
|
||||||
setting = cls.GLOBAL_SETTINGS[key]
|
|
||||||
return setting.get('description', '')
|
|
||||||
else:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_units(cls, key):
|
def get_setting_units(cls, key, **kwargs):
|
||||||
"""
|
"""
|
||||||
Return the units for a particular setting.
|
Return the units for a particular setting.
|
||||||
|
|
||||||
If it does not exist, return an empty string.
|
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:
|
return setting.get('units', '')
|
||||||
setting = cls.GLOBAL_SETTINGS[key]
|
|
||||||
return setting.get('units', '')
|
|
||||||
else:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_validator(cls, key):
|
def get_setting_validator(cls, key, **kwargs):
|
||||||
"""
|
"""
|
||||||
Return the validator for a particular setting.
|
Return the validator for a particular setting.
|
||||||
|
|
||||||
If it does not exist, return None
|
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:
|
return setting.get('validator', None)
|
||||||
setting = cls.GLOBAL_SETTINGS[key]
|
|
||||||
return setting.get('validator', None)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_default(cls, key):
|
def get_setting_default(cls, key, **kwargs):
|
||||||
"""
|
"""
|
||||||
Return the default value for a particular setting.
|
Return the default value for a particular setting.
|
||||||
|
|
||||||
If it does not exist, return an empty string
|
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:
|
return setting.get('default', '')
|
||||||
setting = cls.GLOBAL_SETTINGS[key]
|
|
||||||
return setting.get('default', '')
|
|
||||||
else:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_choices(cls, key):
|
def get_setting_choices(cls, key, **kwargs):
|
||||||
"""
|
"""
|
||||||
Return the validator choices available for a particular setting.
|
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:
|
choices = setting.get('choices', None)
|
||||||
setting = cls.GLOBAL_SETTINGS[key]
|
|
||||||
choices = setting.get('choices', None)
|
|
||||||
else:
|
|
||||||
choices = None
|
|
||||||
|
|
||||||
if callable(choices):
|
if callable(choices):
|
||||||
# Evaluate the function (we expect it will return a list of tuples...)
|
# 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()
|
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:
|
try:
|
||||||
setting = cls.objects.filter(**cls.get_filters(key, **kwargs)).first()
|
setting = settings.filter(**cls.get_filters(key, **kwargs)).first()
|
||||||
except (ValueError, cls.DoesNotExist):
|
except (ValueError, cls.DoesNotExist):
|
||||||
setting = None
|
setting = None
|
||||||
except (IntegrityError, OperationalError):
|
except (IntegrityError, OperationalError):
|
||||||
setting = None
|
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)
|
# Setting does not exist! (Try to create it)
|
||||||
if not setting:
|
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:
|
try:
|
||||||
# Wrap this statement in "atomic", so it can be rolled back if it fails
|
# Wrap this statement in "atomic", so it can be rolled back if it fails
|
||||||
@ -259,21 +277,6 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
return setting
|
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
|
@classmethod
|
||||||
def get_setting(cls, key, backup_value=None, **kwargs):
|
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 no backup value is specified, atttempt to retrieve a "default" value
|
||||||
if backup_value is None:
|
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)
|
setting = cls.get_setting_object(key, **kwargs)
|
||||||
|
|
||||||
if setting:
|
if setting:
|
||||||
value = setting.value
|
value = setting.value
|
||||||
|
|
||||||
# If the particular setting is defined as a boolean, cast the value to a boolean
|
# Cast to boolean if necessary
|
||||||
if setting.is_bool():
|
if setting.is_bool(**kwargs):
|
||||||
value = InvenTree.helpers.str2bool(value)
|
value = InvenTree.helpers.str2bool(value)
|
||||||
|
|
||||||
if setting.is_int():
|
# Cast to integer if necessary
|
||||||
|
if setting.is_int(**kwargs):
|
||||||
try:
|
try:
|
||||||
value = int(value)
|
value = int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@ -357,7 +361,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
def units(self):
|
def units(self):
|
||||||
return self.__class__.get_setting_units(self.key)
|
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,
|
If a validator (or multiple validators) are defined for a particular setting key,
|
||||||
run them against the 'value' field.
|
run them against the 'value' field.
|
||||||
@ -365,25 +369,16 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
validator = self.__class__.get_setting_validator(self.key)
|
validator = self.__class__.get_setting_validator(self.key, **kwargs)
|
||||||
|
|
||||||
if self.is_bool():
|
if validator is not None:
|
||||||
self.value = InvenTree.helpers.str2bool(self.value)
|
self.run_validator(validator)
|
||||||
|
|
||||||
if self.is_int():
|
|
||||||
try:
|
|
||||||
self.value = int(self.value)
|
|
||||||
except (ValueError):
|
|
||||||
raise ValidationError(_('Must be an integer value'))
|
|
||||||
|
|
||||||
options = self.valid_options()
|
options = self.valid_options()
|
||||||
|
|
||||||
if options and self.value not in options:
|
if options and self.value not in options:
|
||||||
raise ValidationError(_("Chosen value is not a valid option"))
|
raise ValidationError(_("Chosen value is not a valid option"))
|
||||||
|
|
||||||
if validator is not None:
|
|
||||||
self.run_validator(validator)
|
|
||||||
|
|
||||||
def run_validator(self, validator):
|
def run_validator(self, validator):
|
||||||
"""
|
"""
|
||||||
Run a validator against the 'value' field for this InvenTreeSetting object.
|
Run a validator against the 'value' field for this InvenTreeSetting object.
|
||||||
@ -395,7 +390,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
value = self.value
|
value = self.value
|
||||||
|
|
||||||
# Boolean validator
|
# Boolean validator
|
||||||
if self.is_bool():
|
if validator is bool:
|
||||||
# Value must "look like" a boolean value
|
# Value must "look like" a boolean value
|
||||||
if InvenTree.helpers.is_bool(value):
|
if InvenTree.helpers.is_bool(value):
|
||||||
# Coerce into either "True" or "False"
|
# Coerce into either "True" or "False"
|
||||||
@ -406,7 +401,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Integer validator
|
# Integer validator
|
||||||
if self.is_int():
|
if validator is int:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Coerce into an integer value
|
# Coerce into an integer value
|
||||||
@ -459,12 +454,12 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
return [opt[0] for opt in choices]
|
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
|
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)
|
return self.__class__.validator_is_bool(validator)
|
||||||
|
|
||||||
@ -477,15 +472,15 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
return InvenTree.helpers.str2bool(self.value)
|
return InvenTree.helpers.str2bool(self.value)
|
||||||
|
|
||||||
def setting_type(self):
|
def setting_type(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
Return the field type identifier for this setting object
|
Return the field type identifier for this setting object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.is_bool():
|
if self.is_bool(**kwargs):
|
||||||
return 'boolean'
|
return 'boolean'
|
||||||
|
|
||||||
elif self.is_int():
|
elif self.is_int(**kwargs):
|
||||||
return 'integer'
|
return 'integer'
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -504,12 +499,12 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def is_int(self):
|
def is_int(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
Check if the setting is required to be an integer value:
|
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)
|
return self.__class__.validator_is_int(validator)
|
||||||
|
|
||||||
@ -541,21 +536,20 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_protected(cls, key):
|
def is_protected(cls, key, **kwargs):
|
||||||
"""
|
"""
|
||||||
Check if the setting value is protected
|
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 setting.get('protected', False)
|
||||||
return cls.GLOBAL_SETTINGS[key].get('protected', False)
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def settings_group_options():
|
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()]]
|
return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]]
|
||||||
|
|
||||||
|
|
||||||
@ -577,7 +571,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
super().save()
|
super().save()
|
||||||
|
|
||||||
if self.requires_restart():
|
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:
|
Dict of all global settings values:
|
||||||
@ -595,7 +589,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
The keys must be upper-case
|
The keys must be upper-case
|
||||||
"""
|
"""
|
||||||
|
|
||||||
GLOBAL_SETTINGS = {
|
SETTINGS = {
|
||||||
|
|
||||||
'SERVER_RESTART_REQUIRED': {
|
'SERVER_RESTART_REQUIRED': {
|
||||||
'name': _('Restart required'),
|
'name': _('Restart required'),
|
||||||
@ -977,13 +971,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
'requires_restart': True,
|
'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': {
|
'ENABLE_PLUGINS_APP': {
|
||||||
'name': _('Enable app integration'),
|
'name': _('Enable app integration'),
|
||||||
'description': _('Enable plugins to add apps'),
|
'description': _('Enable plugins to add apps'),
|
||||||
@ -991,6 +978,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
'requires_restart': True,
|
'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:
|
class Meta:
|
||||||
@ -1017,7 +1011,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
Return True if this setting requires a server restart after changing
|
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:
|
if options:
|
||||||
return options.get('requires_restart', False)
|
return options.get('requires_restart', False)
|
||||||
@ -1030,7 +1024,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
|||||||
An InvenTreeSetting object with a usercontext
|
An InvenTreeSetting object with a usercontext
|
||||||
"""
|
"""
|
||||||
|
|
||||||
GLOBAL_SETTINGS = {
|
SETTINGS = {
|
||||||
'HOMEPAGE_PART_STARRED': {
|
'HOMEPAGE_PART_STARRED': {
|
||||||
'name': _('Show subscribed parts'),
|
'name': _('Show subscribed parts'),
|
||||||
'description': _('Show subscribed parts on the homepage'),
|
'description': _('Show subscribed parts on the homepage'),
|
||||||
|
@ -49,9 +49,9 @@ class SettingsTest(TestCase):
|
|||||||
- Ensure that every global setting has a description.
|
- 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)
|
name = setting.get('name', None)
|
||||||
|
|
||||||
@ -64,14 +64,14 @@ class SettingsTest(TestCase):
|
|||||||
raise ValueError(f'Missing GLOBAL_SETTING description for {key}')
|
raise ValueError(f'Missing GLOBAL_SETTING description for {key}')
|
||||||
|
|
||||||
if not key == key.upper():
|
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):
|
def test_defaults(self):
|
||||||
"""
|
"""
|
||||||
Populate the settings with default values
|
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)
|
value = InvenTreeSetting.get_setting_default(key)
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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.
|
over and above the built-in Django tags.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -22,6 +23,8 @@ import InvenTree.helpers
|
|||||||
from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting
|
from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting
|
||||||
from common.settings import currency_code_default
|
from common.settings import currency_code_default
|
||||||
|
|
||||||
|
from plugin.models import PluginSetting
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@ -223,8 +226,16 @@ def setting_object(key, *args, **kwargs):
|
|||||||
if a user-setting was requested return that
|
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:
|
if 'user' in kwargs:
|
||||||
return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'])
|
return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'])
|
||||||
|
|
||||||
return InvenTreeSetting.get_setting_object(key)
|
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 .integration import IntegrationPluginBase
|
||||||
from .action import ActionPlugin
|
from .action import ActionPlugin
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin',
|
'ActionPlugin',
|
||||||
|
'IntegrationPluginBase',
|
||||||
|
'InvenTreePlugin',
|
||||||
|
'plugin_registry',
|
||||||
]
|
]
|
||||||
|
@ -4,43 +4,70 @@ from __future__ import unicode_literals
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
import plugin.models as models
|
import plugin.models as models
|
||||||
from plugin import plugin_reg
|
import plugin.registry as registry
|
||||||
|
|
||||||
|
|
||||||
def plugin_update(queryset, new_status: bool):
|
def plugin_update(queryset, new_status: bool):
|
||||||
"""general function for bulk changing plugins"""
|
"""
|
||||||
|
General function for bulk changing plugins
|
||||||
|
"""
|
||||||
|
|
||||||
apps_changed = False
|
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:
|
for plugin in queryset:
|
||||||
if plugin.active is not new_status:
|
if plugin.active is not new_status:
|
||||||
plugin.active = new_status
|
plugin.active = new_status
|
||||||
plugin.save(no_reload=True)
|
plugin.save(no_reload=True)
|
||||||
apps_changed = True
|
apps_changed = True
|
||||||
|
|
||||||
# reload plugins if they changed
|
# Reload plugins if they changed
|
||||||
if apps_changed:
|
if apps_changed:
|
||||||
plugin_reg.reload_plugins()
|
registry.plugin_registry.reload_plugins()
|
||||||
|
|
||||||
|
|
||||||
@admin.action(description='Activate plugin(s)')
|
@admin.action(description='Activate plugin(s)')
|
||||||
def plugin_activate(modeladmin, request, queryset):
|
def plugin_activate(modeladmin, request, queryset):
|
||||||
"""activate a set of plugins"""
|
"""
|
||||||
|
Activate a set of plugins
|
||||||
|
"""
|
||||||
plugin_update(queryset, True)
|
plugin_update(queryset, True)
|
||||||
|
|
||||||
|
|
||||||
@admin.action(description='Deactivate plugin(s)')
|
@admin.action(description='Deactivate plugin(s)')
|
||||||
def plugin_deactivate(modeladmin, request, queryset):
|
def plugin_deactivate(modeladmin, request, queryset):
|
||||||
"""deactivate a set of plugins"""
|
"""
|
||||||
|
Deactivate a set of plugins
|
||||||
|
"""
|
||||||
|
|
||||||
plugin_update(queryset, False)
|
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):
|
class PluginConfigAdmin(admin.ModelAdmin):
|
||||||
"""Custom admin with restricted id fields"""
|
"""
|
||||||
|
Custom admin with restricted id fields
|
||||||
|
"""
|
||||||
|
|
||||||
readonly_fields = ["key", "name", ]
|
readonly_fields = ["key", "name", ]
|
||||||
list_display = ['active', '__str__', 'key', 'name', ]
|
list_display = ['name', 'key', '__str__', 'active', ]
|
||||||
list_filter = ['active']
|
list_filter = ['active']
|
||||||
actions = [plugin_activate, plugin_deactivate, ]
|
actions = [plugin_activate, plugin_deactivate, ]
|
||||||
|
inlines = [PluginSettingInline, ]
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.PluginConfig, PluginConfigAdmin)
|
admin.site.register(models.PluginConfig, PluginConfigAdmin)
|
||||||
|
@ -11,7 +11,8 @@ from rest_framework import generics
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
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
|
import plugin.serializers as PluginSerializers
|
||||||
|
|
||||||
|
|
||||||
@ -76,7 +77,46 @@ class PluginInstall(generics.CreateAPIView):
|
|||||||
return serializer.save()
|
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_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
|
# Detail views for a single PluginConfig item
|
||||||
url(r'^(?P<pk>\d+)/', include([
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
|
url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
|
||||||
|
@ -4,17 +4,17 @@ from __future__ import unicode_literals
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from maintenance_mode.core import set_maintenance_mode
|
from maintenance_mode.core import set_maintenance_mode
|
||||||
|
|
||||||
from plugin.registry import plugins
|
from plugin import plugin_registry
|
||||||
|
|
||||||
|
|
||||||
class PluginAppConfig(AppConfig):
|
class PluginAppConfig(AppConfig):
|
||||||
name = 'plugin'
|
name = 'plugin'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
if not plugins.is_loading:
|
if not plugin_registry.is_loading:
|
||||||
# this is the first startup
|
# this is the first startup
|
||||||
plugins.collect_plugins()
|
plugin_registry.collect_plugins()
|
||||||
plugins.load_plugins()
|
plugin_registry.load_plugins()
|
||||||
|
|
||||||
# drop out of maintenance
|
# drop out of maintenance
|
||||||
# makes sure we did not have an error in reloading and maintenance is still active
|
# makes sure we did not have an error in reloading and maintenance is still active
|
||||||
|
@ -2,11 +2,18 @@
|
|||||||
Plugin mixin classes
|
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
|
from plugin.urls import PLUGIN_BASE
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class SettingsMixin:
|
class SettingsMixin:
|
||||||
"""
|
"""
|
||||||
Mixin that enables global settings for the plugin
|
Mixin that enables global settings for the plugin
|
||||||
@ -17,44 +24,160 @@ class SettingsMixin:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('settings', 'has_globalsettings', __class__)
|
self.add_mixin('settings', 'has_settings', __class__)
|
||||||
self.globalsettings = getattr(self, 'SETTINGS', None)
|
self.settings = getattr(self, 'SETTINGS', {})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_globalsettings(self):
|
def has_settings(self):
|
||||||
"""
|
"""
|
||||||
Does this plugin use custom global settings
|
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
|
@property
|
||||||
def globalsettingspatterns(self):
|
def has_scheduled_tasks(self):
|
||||||
"""
|
return bool(self.scheduled_tasks)
|
||||||
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 _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):
|
if not self.has_scheduled_tasks:
|
||||||
"""
|
raise ValueError("SCHEDULED_TASKS not defined")
|
||||||
get plugin global setting by key
|
|
||||||
"""
|
|
||||||
from common.models import InvenTreeSetting
|
|
||||||
return InvenTreeSetting.get_setting(self._globalsetting_name(key))
|
|
||||||
|
|
||||||
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:
|
class UrlsMixin:
|
||||||
@ -116,7 +239,9 @@ class NavigationMixin:
|
|||||||
NAVIGATION_TAB_ICON = "fas fa-question"
|
NAVIGATION_TAB_ICON = "fas fa-question"
|
||||||
|
|
||||||
class MixinMeta:
|
class MixinMeta:
|
||||||
"""meta options for this mixin"""
|
"""
|
||||||
|
meta options for this mixin
|
||||||
|
"""
|
||||||
MIXIN_NAME = 'Navigation Links'
|
MIXIN_NAME = 'Navigation Links'
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
@ -10,14 +10,14 @@ from django.conf import settings
|
|||||||
|
|
||||||
# region logging / errors
|
# region logging / errors
|
||||||
def log_plugin_error(error, reference: str = 'general'):
|
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
|
# make sure the registry is set up
|
||||||
if reference not in plugin_reg.errors:
|
if reference not in plugin_registry.errors:
|
||||||
plugin_reg.errors[reference] = []
|
plugin_registry.errors[reference] = []
|
||||||
|
|
||||||
# add error to stack
|
# add error to stack
|
||||||
plugin_reg.errors[reference].append(error)
|
plugin_registry.errors[reference].append(error)
|
||||||
|
|
||||||
|
|
||||||
class IntegrationPluginError(Exception):
|
class IntegrationPluginError(Exception):
|
||||||
|
@ -9,7 +9,6 @@ import pathlib
|
|||||||
|
|
||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.text import slugify
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
import plugin.plugin as plugin
|
import plugin.plugin as plugin
|
||||||
@ -20,19 +19,27 @@ logger = logging.getLogger("inventree")
|
|||||||
|
|
||||||
|
|
||||||
class MixinBase:
|
class MixinBase:
|
||||||
"""general base for mixins"""
|
"""
|
||||||
|
General base for mixins
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._mixinreg = {}
|
self._mixinreg = {}
|
||||||
self._mixins = {}
|
self._mixins = {}
|
||||||
|
|
||||||
def add_mixin(self, key: str, fnc_enabled=True, cls=None):
|
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._mixins[key] = fnc_enabled
|
||||||
self.setup_mixin(key, cls=cls)
|
self.setup_mixin(key, cls=cls)
|
||||||
|
|
||||||
def setup_mixin(self, key, cls=None):
|
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
|
# get human name
|
||||||
human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
|
human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
|
||||||
|
|
||||||
@ -44,7 +51,10 @@ class MixinBase:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def registered_mixins(self, with_base: bool = False):
|
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)
|
mixins = getattr(self, '_mixinreg', None)
|
||||||
if mixins:
|
if mixins:
|
||||||
# filter out base
|
# filter out base
|
||||||
@ -59,8 +69,6 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
|
|||||||
"""
|
"""
|
||||||
The IntegrationPluginBase class is used to integrate with 3rd party software
|
The IntegrationPluginBase class is used to integrate with 3rd party software
|
||||||
"""
|
"""
|
||||||
PLUGIN_SLUG = None
|
|
||||||
PLUGIN_TITLE = None
|
|
||||||
|
|
||||||
AUTHOR = None
|
AUTHOR = None
|
||||||
DESCRIPTION = None
|
DESCRIPTION = None
|
||||||
@ -84,11 +92,11 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
|
|||||||
# region properties
|
# region properties
|
||||||
@property
|
@property
|
||||||
def slug(self):
|
def slug(self):
|
||||||
"""slug for the plugin"""
|
return self.plugin_slug()
|
||||||
slug = getattr(self, 'PLUGIN_SLUG', None)
|
|
||||||
if not slug:
|
@property
|
||||||
slug = self.plugin_name()
|
def name(self):
|
||||||
return slugify(slug)
|
return self.plugin_name()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def human_name(self):
|
def human_name(self):
|
||||||
|
@ -4,7 +4,7 @@ load templates for loaded plugins
|
|||||||
from django.template.loaders.filesystem import Loader as FilesystemLoader
|
from django.template.loaders.filesystem import Loader as FilesystemLoader
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from plugin import plugin_reg
|
from plugin import plugin_registry
|
||||||
|
|
||||||
|
|
||||||
class PluginTemplateLoader(FilesystemLoader):
|
class PluginTemplateLoader(FilesystemLoader):
|
||||||
@ -12,7 +12,7 @@ class PluginTemplateLoader(FilesystemLoader):
|
|||||||
def get_dirs(self):
|
def get_dirs(self):
|
||||||
dirname = 'templates'
|
dirname = 'templates'
|
||||||
template_dirs = []
|
template_dirs = []
|
||||||
for plugin in plugin_reg.plugins.values():
|
for plugin in plugin_registry.plugins.values():
|
||||||
new_path = Path(plugin.path) / dirname
|
new_path = Path(plugin.path) / dirname
|
||||||
if Path(new_path).is_dir():
|
if Path(new_path).is_dir():
|
||||||
template_dirs.append(new_path)
|
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__ = [
|
__all__ = [
|
||||||
'AppMixin',
|
'AppMixin',
|
||||||
'NavigationMixin',
|
'NavigationMixin',
|
||||||
|
'ScheduleMixin',
|
||||||
'SettingsMixin',
|
'SettingsMixin',
|
||||||
'UrlsMixin',
|
'UrlsMixin',
|
||||||
]
|
]
|
||||||
|
@ -8,16 +8,17 @@ from __future__ import unicode_literals
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from plugin import plugin_reg
|
import common.models
|
||||||
|
|
||||||
|
from plugin import InvenTreePlugin, plugin_registry
|
||||||
|
|
||||||
|
|
||||||
class PluginConfig(models.Model):
|
class PluginConfig(models.Model):
|
||||||
""" A PluginConfig object holds settings for plugins.
|
"""
|
||||||
|
A PluginConfig object holds settings for plugins.
|
||||||
It is used to designate a Part as 'subscribed' for a given User.
|
|
||||||
|
|
||||||
Attributes:
|
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
|
name: PluginName of the plugin - serves for a manual double check if the right plugin is used
|
||||||
active: Should the plugin be loaded?
|
active: Should the plugin be loaded?
|
||||||
"""
|
"""
|
||||||
@ -63,12 +64,15 @@ class PluginConfig(models.Model):
|
|||||||
# functions
|
# functions
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
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)
|
super().__init__(*args, **kwargs)
|
||||||
self.__org_active = self.active
|
self.__org_active = self.active
|
||||||
|
|
||||||
# append settings from registry
|
# 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):
|
def get_plugin_meta(name):
|
||||||
if self.plugin:
|
if self.plugin:
|
||||||
@ -82,16 +86,112 @@ class PluginConfig(models.Model):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def save(self, force_insert=False, force_update=False, *args, **kwargs):
|
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
|
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
||||||
|
|
||||||
ret = super().save(force_insert, force_update, *args, **kwargs)
|
ret = super().save(force_insert, force_update, *args, **kwargs)
|
||||||
|
|
||||||
if not reload:
|
if not reload:
|
||||||
if self.active is False and self.__org_active is True:
|
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:
|
elif self.active is True and self.__org_active is False:
|
||||||
plugin_reg.reload_plugins()
|
plugin_registry.reload_plugins()
|
||||||
|
|
||||||
return ret
|
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 -*-
|
# -*- 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():
|
class InvenTreePlugin():
|
||||||
@ -7,12 +12,54 @@ class InvenTreePlugin():
|
|||||||
Base class for a plugin
|
Base class for a plugin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
# Override the plugin name for each concrete plugin instance
|
# Override the plugin name for each concrete plugin instance
|
||||||
PLUGIN_NAME = ''
|
PLUGIN_NAME = ''
|
||||||
|
|
||||||
|
PLUGIN_SLUG = None
|
||||||
|
|
||||||
|
PLUGIN_TITLE = None
|
||||||
|
|
||||||
def plugin_name(self):
|
def plugin_name(self):
|
||||||
"""get plugin name"""
|
"""
|
||||||
|
Return the name of this plugin plugin
|
||||||
|
"""
|
||||||
return self.PLUGIN_NAME
|
return self.PLUGIN_NAME
|
||||||
|
|
||||||
def __init__(self):
|
def plugin_slug(self):
|
||||||
pass
|
|
||||||
|
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
|
Registry for loading and managing multiple plugins at run-time
|
||||||
holds the class and the object that contains all code to maintain plugin states
|
|
||||||
|
- 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 importlib
|
||||||
import pathlib
|
import pathlib
|
||||||
import logging
|
import logging
|
||||||
@ -33,7 +36,11 @@ from .helpers import get_plugin_error, IntegrationPluginError
|
|||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class Plugins:
|
class PluginsRegistry:
|
||||||
|
"""
|
||||||
|
The PluginsRegistry class
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# plugin registry
|
# plugin registry
|
||||||
self.plugins = {}
|
self.plugins = {}
|
||||||
@ -50,15 +57,19 @@ class Plugins:
|
|||||||
# integration specific
|
# integration specific
|
||||||
self.installed_apps = [] # Holds all added plugin_paths
|
self.installed_apps = [] # Holds all added plugin_paths
|
||||||
# mixins
|
# mixins
|
||||||
self.mixins_globalsettings = {}
|
self.mixins_settings = {}
|
||||||
|
|
||||||
# region public plugin functions
|
# region public plugin functions
|
||||||
def load_plugins(self):
|
def load_plugins(self):
|
||||||
"""load and activate all IntegrationPlugins"""
|
"""
|
||||||
|
Load and activate all IntegrationPlugins
|
||||||
|
"""
|
||||||
|
|
||||||
from plugin.helpers import log_plugin_error
|
from plugin.helpers import log_plugin_error
|
||||||
|
|
||||||
logger.info('Start loading plugins')
|
logger.info('Start loading plugins')
|
||||||
# set maintanace mode
|
|
||||||
|
# Set maintanace mode
|
||||||
_maintenance = bool(get_maintenance_mode())
|
_maintenance = bool(get_maintenance_mode())
|
||||||
if not _maintenance:
|
if not _maintenance:
|
||||||
set_maintenance_mode(True)
|
set_maintenance_mode(True)
|
||||||
@ -68,7 +79,7 @@ class Plugins:
|
|||||||
retry_counter = settings.PLUGIN_RETRY
|
retry_counter = settings.PLUGIN_RETRY
|
||||||
while not registered_sucessfull:
|
while not registered_sucessfull:
|
||||||
try:
|
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._init_plugins(blocked_plugin)
|
||||||
self._activate_plugins()
|
self._activate_plugins()
|
||||||
registered_sucessfull = True
|
registered_sucessfull = True
|
||||||
@ -81,13 +92,14 @@ class Plugins:
|
|||||||
log_plugin_error({error.path: error.message}, 'load')
|
log_plugin_error({error.path: error.message}, 'load')
|
||||||
blocked_plugin = error.path # we will not try to load this app again
|
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_registry()
|
||||||
self._clean_installed_apps()
|
self._clean_installed_apps()
|
||||||
self._activate_plugins(force_reload=True)
|
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
|
retry_counter -= 1
|
||||||
|
|
||||||
if retry_counter <= 0:
|
if retry_counter <= 0:
|
||||||
if settings.PLUGIN_TESTING:
|
if settings.PLUGIN_TESTING:
|
||||||
print('[PLUGIN] Max retries, breaking loading')
|
print('[PLUGIN] Max retries, breaking loading')
|
||||||
@ -98,15 +110,20 @@ class Plugins:
|
|||||||
|
|
||||||
# now the loading will re-start up with init
|
# now the loading will re-start up with init
|
||||||
|
|
||||||
# remove maintenance
|
# Remove maintenance mode
|
||||||
if not _maintenance:
|
if not _maintenance:
|
||||||
set_maintenance_mode(False)
|
set_maintenance_mode(False)
|
||||||
|
|
||||||
logger.info('Finished loading plugins')
|
logger.info('Finished loading plugins')
|
||||||
|
|
||||||
def unload_plugins(self):
|
def unload_plugins(self):
|
||||||
"""unload and deactivate all IntegrationPlugins"""
|
"""
|
||||||
|
Unload and deactivate all IntegrationPlugins
|
||||||
|
"""
|
||||||
|
|
||||||
logger.info('Start unloading plugins')
|
logger.info('Start unloading plugins')
|
||||||
# set maintanace mode
|
|
||||||
|
# Set maintanace mode
|
||||||
_maintenance = bool(get_maintenance_mode())
|
_maintenance = bool(get_maintenance_mode())
|
||||||
if not _maintenance:
|
if not _maintenance:
|
||||||
set_maintenance_mode(True)
|
set_maintenance_mode(True)
|
||||||
@ -123,21 +140,27 @@ class Plugins:
|
|||||||
logger.info('Finished unloading plugins')
|
logger.info('Finished unloading plugins')
|
||||||
|
|
||||||
def reload_plugins(self):
|
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:
|
if self.is_loading:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info('Start reloading plugins')
|
logger.info('Start reloading plugins')
|
||||||
|
|
||||||
with maintenance_mode_on():
|
with maintenance_mode_on():
|
||||||
self.unload_plugins()
|
self.unload_plugins()
|
||||||
self.load_plugins()
|
self.load_plugins()
|
||||||
logger.info('Finished reloading plugins')
|
|
||||||
# endregion
|
|
||||||
|
|
||||||
# region general plugin managment mechanisms
|
logger.info('Finished reloading plugins')
|
||||||
|
|
||||||
def collect_plugins(self):
|
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
|
self.plugin_modules = [] # clear
|
||||||
|
|
||||||
# Collect plugins from paths
|
# Collect plugins from paths
|
||||||
@ -146,7 +169,7 @@ class Plugins:
|
|||||||
if modules:
|
if modules:
|
||||||
[self.plugin_modules.append(item) for item in 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):
|
if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP):
|
||||||
# Collect plugins from setup entry points
|
# Collect plugins from setup entry points
|
||||||
for entry in metadata.entry_points().get('inventree_plugins', []):
|
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]))
|
logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
|
||||||
|
|
||||||
def _init_plugins(self, disabled=None):
|
def _init_plugins(self, disabled=None):
|
||||||
"""initialise all found plugins
|
"""
|
||||||
|
Initialise all found plugins
|
||||||
|
|
||||||
:param disabled: loading path of disabled app, defaults to None
|
:param disabled: loading path of disabled app, defaults to None
|
||||||
:type disabled: str, optional
|
:type disabled: str, optional
|
||||||
:raises error: IntegrationPluginError
|
:raises error: IntegrationPluginError
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from plugin.models import PluginConfig
|
from plugin.models import PluginConfig
|
||||||
|
|
||||||
logger.info('Starting plugin initialisation')
|
logger.info('Starting plugin initialisation')
|
||||||
|
|
||||||
# Initialize integration plugins
|
# Initialize integration plugins
|
||||||
for plugin in self.plugin_modules:
|
for plugin in self.plugin_modules:
|
||||||
# check if package
|
# Check if package
|
||||||
was_packaged = getattr(plugin, 'is_package', False)
|
was_packaged = getattr(plugin, 'is_package', False)
|
||||||
|
|
||||||
# check if activated
|
# Check if activated
|
||||||
# these checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
|
# These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
|
||||||
plug_name = plugin.PLUGIN_NAME
|
plug_name = plugin.PLUGIN_NAME
|
||||||
plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
|
plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
|
||||||
plug_key = slugify(plug_key) # keys are slugs!
|
plug_key = slugify(plug_key) # keys are slugs!
|
||||||
@ -189,23 +215,23 @@ class Plugins:
|
|||||||
raise error
|
raise error
|
||||||
plugin_db_setting = None
|
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):
|
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:
|
if disabled:
|
||||||
# option1: package, option2: file-based
|
# option1: package, option2: file-based
|
||||||
if (plugin.__name__ == disabled) or (plugin.__module__ == disabled):
|
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:
|
if not settings.PLUGIN_TESTING:
|
||||||
plugin_db_setting.active = False
|
plugin_db_setting.active = False
|
||||||
# TODO save the error to the plugin
|
# TODO save the error to the plugin
|
||||||
plugin_db_setting.save(no_reload=True)
|
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
|
self.plugins_inactive[plug_key] = plugin_db_setting
|
||||||
continue # continue -> the plugin is not loaded
|
continue # continue -> the plugin is not loaded
|
||||||
|
|
||||||
# init package
|
# Initialize package
|
||||||
# now we can be sure that an admin has activated the plugin
|
# 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
|
# 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.
|
# 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
|
self.plugins_inactive[plug_key] = plugin_db_setting
|
||||||
|
|
||||||
def _activate_plugins(self, force_reload=False):
|
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
|
:param force_reload: force reload base apps, defaults to False
|
||||||
:type force_reload: bool, optional
|
:type force_reload: bool, optional
|
||||||
@ -237,49 +264,91 @@ class Plugins:
|
|||||||
plugins = self.plugins.items()
|
plugins = self.plugins.items()
|
||||||
logger.info(f'Found {len(plugins)} active plugins')
|
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)
|
self.activate_integration_app(plugins, force_reload=force_reload)
|
||||||
|
|
||||||
def _deactivate_plugins(self):
|
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_app()
|
||||||
self.deactivate_integration_globalsettings()
|
self.deactivate_integration_schedule()
|
||||||
# endregion
|
self.deactivate_integration_settings()
|
||||||
|
|
||||||
# region specific integrations
|
def activate_integration_settings(self, plugins):
|
||||||
# region integration_globalsettings
|
|
||||||
def activate_integration_globalsettings(self, plugins):
|
|
||||||
from common.models import InvenTreeSetting
|
|
||||||
|
|
||||||
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'):
|
logger.info('Activating plugin settings')
|
||||||
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
|
|
||||||
|
|
||||||
# Add to settings dir
|
self.mixins_settings = {}
|
||||||
InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
|
|
||||||
|
|
||||||
def deactivate_integration_globalsettings(self):
|
for slug, plugin in plugins:
|
||||||
from common.models import InvenTreeSetting
|
if plugin.mixin_enabled('settings'):
|
||||||
|
plugin_setting = plugin.settings
|
||||||
|
self.mixins_settings[slug] = plugin_setting
|
||||||
|
|
||||||
|
def deactivate_integration_settings(self):
|
||||||
|
|
||||||
# collect all settings
|
# collect all settings
|
||||||
plugin_settings = {}
|
plugin_settings = {}
|
||||||
for _, plugin_setting in self.mixins_globalsettings.items():
|
|
||||||
|
for _, plugin_setting in self.mixins_settings.items():
|
||||||
plugin_settings.update(plugin_setting)
|
plugin_settings.update(plugin_setting)
|
||||||
|
|
||||||
# remove settings
|
|
||||||
for setting in plugin_settings:
|
|
||||||
InvenTreeSetting.GLOBAL_SETTINGS.pop(setting)
|
|
||||||
|
|
||||||
# clear cache
|
# clear cache
|
||||||
self.mixins_globalsettings = {}
|
self.mixins_settings = {}
|
||||||
# endregion
|
|
||||||
|
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):
|
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
|
:param plugins: list of IntegrationPlugins that should be installed
|
||||||
:type plugins: dict
|
:type plugins: dict
|
||||||
@ -363,7 +432,10 @@ class Plugins:
|
|||||||
return plugin_path
|
return plugin_path
|
||||||
|
|
||||||
def deactivate_integration_app(self):
|
def deactivate_integration_app(self):
|
||||||
"""deactivate integration app - some magic required"""
|
"""
|
||||||
|
Deactivate integration app - some magic required
|
||||||
|
"""
|
||||||
|
|
||||||
# unregister models from admin
|
# unregister models from admin
|
||||||
for plugin_path in self.installed_apps:
|
for plugin_path in self.installed_apps:
|
||||||
models = [] # the modelrefs need to be collected as poping an item in a iter is not welcomed
|
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, []
|
return True, []
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
get_plugin_error(error, do_raise=True)
|
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,
|
'default': True,
|
||||||
'validator': bool,
|
'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 = [
|
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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
@ -15,12 +15,14 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from plugin.models import PluginConfig
|
from plugin.models import PluginConfig, PluginSetting
|
||||||
from InvenTree.config import get_plugin_file
|
from InvenTree.config import get_plugin_file
|
||||||
|
from common.serializers import SettingsSerializer
|
||||||
|
|
||||||
|
|
||||||
class PluginConfigSerializer(serializers.ModelSerializer):
|
class PluginConfigSerializer(serializers.ModelSerializer):
|
||||||
""" Serializer for a PluginConfig:
|
"""
|
||||||
|
Serializer for a PluginConfig:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
meta = serializers.DictField(read_only=True)
|
meta = serializers.DictField(read_only=True)
|
||||||
@ -73,7 +75,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
|
|||||||
if not data.get('confirm'):
|
if not data.get('confirm'):
|
||||||
raise ValidationError({'confirm': _('Installation not confirmed')})
|
raise ValidationError({'confirm': _('Installation not confirmed')})
|
||||||
if (not data.get('url')) and (not data.get('packagename')):
|
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})
|
raise ValidationError({'url': msg, 'packagename': msg})
|
||||||
|
|
||||||
return data
|
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')
|
plugin_file.write(f'{" ".join(install_name)} # Installed {timezone.now()} by {str(self.context["request"].user)}\n')
|
||||||
|
|
||||||
return ret
|
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 django.urls import reverse
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from plugin import plugin_reg
|
from plugin import plugin_registry
|
||||||
|
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
@ -16,19 +16,19 @@ register = template.Library()
|
|||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def plugin_list(*args, **kwargs):
|
def plugin_list(*args, **kwargs):
|
||||||
""" Return a list of all installed integration plugins """
|
""" Return a list of all installed integration plugins """
|
||||||
return plugin_reg.plugins
|
return plugin_registry.plugins
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def inactive_plugin_list(*args, **kwargs):
|
def inactive_plugin_list(*args, **kwargs):
|
||||||
""" Return a list of all inactive integration plugins """
|
""" Return a list of all inactive integration plugins """
|
||||||
return plugin_reg.plugins_inactive
|
return plugin_registry.plugins_inactive
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def plugin_globalsettings(plugin, *args, **kwargs):
|
def plugin_settings(plugin, *args, **kwargs):
|
||||||
""" Return a list of all global settings for a plugin """
|
""" Return a list of all custom settings for a plugin """
|
||||||
return plugin_reg.mixins_globalsettings.get(plugin)
|
return plugin_registry.mixins_settings.get(plugin)
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
@ -57,4 +57,4 @@ def safe_url(view_name, *args, **kwargs):
|
|||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def plugin_errors(*args, **kwargs):
|
def plugin_errors(*args, **kwargs):
|
||||||
"""Return all plugin errors"""
|
"""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):
|
class PluginDetailAPITest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
Tests the plugin AP I endpoints
|
Tests the plugin API endpoints
|
||||||
"""
|
"""
|
||||||
|
|
||||||
roles = [
|
roles = [
|
||||||
@ -19,7 +19,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
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'
|
self.PKG_NAME = 'minimal'
|
||||||
super().setUp()
|
super().setUp()
|
||||||
@ -64,14 +64,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
|
|||||||
Test the PluginConfig action commands
|
Test the PluginConfig action commands
|
||||||
"""
|
"""
|
||||||
from plugin.models import PluginConfig
|
from plugin.models import PluginConfig
|
||||||
from plugin import plugin_reg
|
from plugin import plugin_registry
|
||||||
|
|
||||||
url = reverse('admin:plugin_pluginconfig_changelist')
|
url = reverse('admin:plugin_pluginconfig_changelist')
|
||||||
fixtures = PluginConfig.objects.all()
|
fixtures = PluginConfig.objects.all()
|
||||||
|
|
||||||
# check if plugins were registered -> in some test setups the startup has no db access
|
# check if plugins were registered -> in some test setups the startup has no db access
|
||||||
if not fixtures:
|
if not fixtures:
|
||||||
plugin_reg.reload_plugins()
|
plugin_registry.reload_plugins()
|
||||||
fixtures = PluginConfig.objects.all()
|
fixtures = PluginConfig.objects.all()
|
||||||
|
|
||||||
print([str(a) for a in fixtures])
|
print([str(a) for a in fixtures])
|
||||||
|
@ -23,7 +23,7 @@ class BaseMixinDefinition:
|
|||||||
class SettingsMixinTest(BaseMixinDefinition, TestCase):
|
class SettingsMixinTest(BaseMixinDefinition, TestCase):
|
||||||
MIXIN_HUMAN_NAME = 'Settings'
|
MIXIN_HUMAN_NAME = 'Settings'
|
||||||
MIXIN_NAME = 'settings'
|
MIXIN_NAME = 'settings'
|
||||||
MIXIN_ENABLE_CHECK = 'has_globalsettings'
|
MIXIN_ENABLE_CHECK = 'has_settings'
|
||||||
|
|
||||||
TEST_SETTINGS = {'SETTING1': {'default': '123', }}
|
TEST_SETTINGS = {'SETTING1': {'default': '123', }}
|
||||||
|
|
||||||
@ -42,25 +42,19 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
|
|
||||||
def test_function(self):
|
def test_function(self):
|
||||||
# settings variable
|
# settings variable
|
||||||
self.assertEqual(self.mixin.globalsettings, self.TEST_SETTINGS)
|
self.assertEqual(self.mixin.settings, 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)
|
|
||||||
|
|
||||||
# calling settings
|
# calling settings
|
||||||
# not existing
|
# not existing
|
||||||
self.assertEqual(self.mixin.get_globalsetting('ABCD'), '')
|
self.assertEqual(self.mixin.get_setting('ABCD'), '')
|
||||||
self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '')
|
self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '')
|
||||||
|
|
||||||
# right setting
|
# right setting
|
||||||
self.mixin.set_globalsetting('SETTING1', '12345', self.test_user)
|
self.mixin.set_setting('SETTING1', '12345', self.test_user)
|
||||||
self.assertEqual(self.mixin.get_globalsetting('SETTING1'), '12345')
|
self.assertEqual(self.mixin.get_setting('SETTING1'), '12345')
|
||||||
|
|
||||||
# no setting
|
# no setting
|
||||||
self.assertEqual(self.mixin_nothing.get_globalsetting(''), '')
|
self.assertEqual(self.mixin_nothing.get_setting(''), '')
|
||||||
|
|
||||||
|
|
||||||
class UrlsMixinTest(BaseMixinDefinition, TestCase):
|
class UrlsMixinTest(BaseMixinDefinition, TestCase):
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
""" Unit tests for plugins """
|
"""
|
||||||
|
Unit tests for plugins
|
||||||
|
"""
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
@ -6,9 +8,8 @@ import plugin.plugin
|
|||||||
import plugin.integration
|
import plugin.integration
|
||||||
from plugin.samples.integration.sample import SampleIntegrationPlugin
|
from plugin.samples.integration.sample import SampleIntegrationPlugin
|
||||||
from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
|
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
|
import plugin.templatetags.plugin_extras as plugin_tags
|
||||||
from plugin import plugin_reg
|
from plugin import plugin_registry
|
||||||
|
|
||||||
|
|
||||||
class InvenTreePluginTests(TestCase):
|
class InvenTreePluginTests(TestCase):
|
||||||
@ -57,17 +58,17 @@ class PluginTagTests(TestCase):
|
|||||||
|
|
||||||
def test_tag_plugin_list(self):
|
def test_tag_plugin_list(self):
|
||||||
"""test that all plugins are listed"""
|
"""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):
|
def test_tag_incative_plugin_list(self):
|
||||||
"""test that all inactive plugins are listed"""
|
"""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"""
|
"""check all plugins are listed"""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
plugin_tags.plugin_globalsettings(self.sample),
|
plugin_tags.plugin_settings(self.sample),
|
||||||
plugin_reg.mixins_globalsettings.get(self.sample)
|
plugin_registry.mixins_settings.get(self.sample)
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_tag_mixin_enabled(self):
|
def test_tag_mixin_enabled(self):
|
||||||
@ -89,4 +90,4 @@ class PluginTagTests(TestCase):
|
|||||||
|
|
||||||
def test_tag_plugin_errors(self):
|
def test_tag_plugin_errors(self):
|
||||||
"""test that all errors are listed"""
|
"""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
|
URL lookup for plugin app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
from plugin import plugin_reg
|
from plugin import plugin_registry
|
||||||
|
|
||||||
|
|
||||||
PLUGIN_BASE = 'plugin' # Constant for links
|
PLUGIN_BASE = 'plugin' # Constant for links
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_urls():
|
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 = []
|
urls = []
|
||||||
for plugin in plugin_reg.plugins.values():
|
|
||||||
|
for plugin in plugin_registry.plugins.values():
|
||||||
if plugin.mixin_enabled('urls'):
|
if plugin.mixin_enabled('urls'):
|
||||||
urls.append(plugin.urlpatterns)
|
urls.append(plugin.urlpatterns)
|
||||||
|
|
||||||
return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))
|
return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))
|
||||||
|
@ -13,19 +13,19 @@
|
|||||||
|
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %}
|
{% 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-info-circle" %}
|
{% 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-info-circle" %}
|
{% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-at" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" %}
|
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" icon='fa-key' %}
|
||||||
<tr>
|
<tr>
|
||||||
<th><h5>{% trans 'Signup' %}</h5></th>
|
<th><h5>{% trans 'Signup' %}</h5></th>
|
||||||
<td colspan='4'></td>
|
<td colspan='4'></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %}
|
{% 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-info-circle" %}
|
{% 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-info-circle" %}
|
{% 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-info-circle" %}
|
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-key" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" %}
|
{% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" icon="fa-users" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
@ -5,12 +5,12 @@
|
|||||||
<h4>{% trans "Settings" %}</h4>
|
<h4>{% trans "Settings" %}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% plugin_globalsettings plugin_key as plugin_settings %}
|
{% plugin_settings plugin_key as plugin_settings %}
|
||||||
|
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for setting in plugin_settings %}
|
{% for setting in plugin_settings %}
|
||||||
{% include "InvenTree/settings/setting.html" with key=setting%}
|
{% include "InvenTree/settings/setting.html" with key=setting plugin=plugin %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
@ -19,17 +19,17 @@
|
|||||||
<div class='table-responsive'>
|
<div class='table-responsive'>
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %}
|
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_SCHEDULE" icon="fa-calendar-alt" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %}
|
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_GLOBALSETTING"%}
|
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%}
|
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<div class='d-flex flex-wrap'>
|
<div class='d-flex flex-wrap'>
|
||||||
<h4>{% trans "Plugin list" %}</h4>
|
<h4>{% trans "Plugins" %}</h4>
|
||||||
{% include "spacer.html" %}
|
{% include "spacer.html" %}
|
||||||
<div class='btn-group' role='group'>
|
<div class='btn-group' role='group'>
|
||||||
{% url 'admin:plugin_pluginconfig_changelist' as url %}
|
{% url 'admin:plugin_pluginconfig_changelist' as url %}
|
||||||
@ -70,7 +70,7 @@
|
|||||||
{% if mixin_list %}
|
{% if mixin_list %}
|
||||||
{% for mixin in mixin_list %}
|
{% for mixin in mixin_list %}
|
||||||
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
|
<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>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -12,10 +12,10 @@
|
|||||||
|
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" %}
|
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="file-pdf" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" %}
|
{% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" %}
|
{% 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" %}
|
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% if user_setting %}
|
{% if plugin %}
|
||||||
{% setting_object key user=request.user as setting %}
|
{% setting_object key plugin=plugin as setting %}
|
||||||
|
{% elif user_setting %}
|
||||||
|
{% setting_object key user=request.user as setting %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% setting_object key as setting %}
|
{% setting_object key as setting %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@ -13,7 +15,7 @@
|
|||||||
<span class='fas {{ icon }}'></span>
|
<span class='fas {{ icon }}'></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td><strong>{% trans setting.name %}</strong></td>
|
<td><strong>{{ setting.name }}</strong></td>
|
||||||
<td>
|
<td>
|
||||||
{% if setting.is_bool %}
|
{% if setting.is_bool %}
|
||||||
<div class='form-check form-switch'>
|
<div class='form-check form-switch'>
|
||||||
@ -32,11 +34,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>
|
<td>
|
||||||
{% trans setting.description %}
|
{{ setting.description }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class='btn-group float-right'>
|
<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>
|
<span class='fas fa-edit icon-green'></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -62,16 +62,27 @@
|
|||||||
$('table').find('.btn-edit-setting').click(function() {
|
$('table').find('.btn-edit-setting').click(function() {
|
||||||
var setting = $(this).attr('setting');
|
var setting = $(this).attr('setting');
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
|
var plugin = $(this).attr('plugin');
|
||||||
var is_global = true;
|
var is_global = true;
|
||||||
|
|
||||||
if ($(this).attr('user')){
|
if ($(this).attr('user')){
|
||||||
is_global = false;
|
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, {
|
editSetting(pk, {
|
||||||
|
plugin: plugin,
|
||||||
global: is_global,
|
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_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 %}
|
{% plugin_list as pl_list %}
|
||||||
{% for plugin_key, plugin in pl_list.items %}
|
{% 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?
|
// Is this a global setting or a user setting?
|
||||||
var global = options.global || false;
|
var global = options.global || false;
|
||||||
|
|
||||||
|
var plugin = options.plugin;
|
||||||
|
|
||||||
var url = '';
|
var url = '';
|
||||||
|
|
||||||
if (global) {
|
if (plugin) {
|
||||||
|
url = `/api/plugin/settings/${pk}/`;
|
||||||
|
} else if (global) {
|
||||||
url = `/api/settings/global/${pk}/`;
|
url = `/api/settings/global/${pk}/`;
|
||||||
} else {
|
} else {
|
||||||
url = `/api/settings/user/${pk}/`;
|
url = `/api/settings/user/${pk}/`;
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
allocateStockToBuild,
|
allocateStockToBuild,
|
||||||
|
completeBuildOrder,
|
||||||
editBuildOrder,
|
editBuildOrder,
|
||||||
loadAllocationTable,
|
loadAllocationTable,
|
||||||
loadBuildOrderAllocationTable,
|
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
|
* 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" %}`;
|
return `${pageNumber} {% trans "rows per page" %}`;
|
||||||
},
|
},
|
||||||
formatShowingRows: function(pageFrom, pageTo, totalRows) {
|
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() {
|
formatSearch: function() {
|
||||||
return '{% trans "Search" %}';
|
return '{% trans "Search" %}';
|
||||||
|
@ -76,7 +76,8 @@ class RuleSet(models.Model):
|
|||||||
'otp_totp_totpdevice',
|
'otp_totp_totpdevice',
|
||||||
'otp_static_statictoken',
|
'otp_static_statictoken',
|
||||||
'otp_static_staticdevice',
|
'otp_static_staticdevice',
|
||||||
'plugin_pluginconfig'
|
'plugin_pluginconfig',
|
||||||
|
'plugin_pluginsetting',
|
||||||
],
|
],
|
||||||
'part_category': [
|
'part_category': [
|
||||||
'part_partcategory',
|
'part_partcategory',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user