Merge branch 'master' of https://github.com/inventree/InvenTree into api-mixin

This commit is contained in:
Matthias 2022-01-08 21:50:37 +01:00
commit 3bc3e98ed1
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
82 changed files with 10834 additions and 9705 deletions

25
.github/workflows/stale.yml vendored Normal file
View 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

17
.github/workflows/welcome.yml vendored Normal file
View File

@ -0,0 +1,17 @@
# welcome new contributers
name: Welcome
on:
pull_request:
types: [opened]
issues:
types: [opened]
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: 'Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help.\nIf you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/).'
pr-message: 'This is your first PR, welcome!\nPlease check [Contributing](https://github.com/inventree/InvenTree/blob/master/CONTRIBUTING.md) to make sure your submission fits our general code-style and workflow.\nMake sure to document why this PR is needed and to link connected issues so we can review it faster.'

1
.gitignore vendored
View File

@ -49,6 +49,7 @@ static_i18n
# Local config file
config.yaml
plugins.txt
# Default data file
data.json

View File

@ -0,0 +1,90 @@
"""
Helper functions for loading InvenTree configuration options
"""
import os
import shutil
import logging
logger = logging.getLogger('inventree')
def get_base_dir():
""" Returns the base (top-level) InvenTree directory """
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def get_config_file():
"""
Returns the path of the InvenTree configuration file.
Note: It will be created it if does not already exist!
"""
base_dir = get_base_dir()
cfg_filename = os.getenv('INVENTREE_CONFIG_FILE')
if cfg_filename:
cfg_filename = cfg_filename.strip()
cfg_filename = os.path.abspath(cfg_filename)
else:
# Config file is *not* specified - use the default
cfg_filename = os.path.join(base_dir, 'config.yaml')
if not os.path.exists(cfg_filename):
print("InvenTree configuration file 'config.yaml' not found - creating default file")
cfg_template = os.path.join(base_dir, "config_template.yaml")
shutil.copyfile(cfg_template, cfg_filename)
print(f"Created config file {cfg_filename}")
return cfg_filename
def get_plugin_file():
"""
Returns the path of the InvenTree plugins specification file.
Note: It will be created if it does not already exist!
"""
# Check if the plugin.txt file (specifying required plugins) is specified
PLUGIN_FILE = os.getenv('INVENTREE_PLUGIN_FILE')
if not PLUGIN_FILE:
# If not specified, look in the same directory as the configuration file
config_dir = os.path.dirname(get_config_file())
PLUGIN_FILE = os.path.join(config_dir, 'plugins.txt')
if not os.path.exists(PLUGIN_FILE):
logger.warning("Plugin configuration file does not exist")
logger.info(f"Creating plugin file at '{PLUGIN_FILE}'")
# If opening the file fails (no write permission, for example), then this will throw an error
with open(PLUGIN_FILE, 'w') as plugin_file:
plugin_file.write("# InvenTree Plugins (uses PIP framework to install)\n\n")
return PLUGIN_FILE
def get_setting(environment_var, backup_val, default_value=None):
"""
Helper function for retrieving a configuration setting value
- First preference is to look for the environment variable
- Second preference is to look for the value of the settings file
- Third preference is the default value
"""
val = os.getenv(environment_var)
if val is not None:
return val
if backup_val is not None:
return backup_val
return default_value

View File

@ -404,21 +404,28 @@ def DownloadFile(data, filename, content_type='application/text', inline=False):
return response
def extract_serial_numbers(serials, expected_quantity):
def extract_serial_numbers(serials, expected_quantity, next_number: int):
""" Attempt to extract serial numbers from an input string.
- Serial numbers must be integer values
- Serial numbers must be positive
- Serial numbers can be split by whitespace / newline / commma chars
- Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20
- Serial numbers can be defined as ~ for getting the next available serial number
- Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start>
- Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start>
Args:
serials: input string with patterns
expected_quantity: The number of (unique) serial numbers we expect
next_number(int): the next possible serial number
"""
serials = serials.strip()
# fill in the next serial number into the serial
if '~' in serials:
serials = serials.replace('~', str(next_number))
groups = re.split("[\s,]+", serials)
numbers = []
@ -493,11 +500,20 @@ def extract_serial_numbers(serials, expected_quantity):
errors.append(_("Invalid group: {g}").format(g=group))
continue
# Group should be a number
elif group:
# try conversion
try:
number = int(group)
except:
# seem like it is not a number
raise ValidationError(_(f"Invalid group {group}"))
number_add(number)
# No valid input group detected
else:
if group in numbers:
errors.append(_("Duplicate serial: {g}".format(g=group)))
else:
numbers.append(group)
raise ValidationError(_(f"Invalid/no group {group}"))
if len(errors) > 0:
raise ValidationError(errors)

View File

@ -2,9 +2,14 @@
Custom management command to cleanup old settings that are not defined anymore
"""
import logging
from django.core.management.base import BaseCommand
logger = logging.getLogger('inventree')
class Command(BaseCommand):
"""
Cleanup old (undefined) settings in the database
@ -12,27 +17,27 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
print("Collecting settings")
logger.info("Collecting settings")
from common.models import InvenTreeSetting, InvenTreeUserSetting
# general settings
db_settings = InvenTreeSetting.objects.all()
model_settings = InvenTreeSetting.GLOBAL_SETTINGS
model_settings = InvenTreeSetting.SETTINGS
# check if key exist and delete if not
for setting in db_settings:
if setting.key not in model_settings:
setting.delete()
print(f"deleted setting '{setting.key}'")
logger.info(f"deleted setting '{setting.key}'")
# user settings
db_settings = InvenTreeUserSetting.objects.all()
model_settings = InvenTreeUserSetting.GLOBAL_SETTINGS
model_settings = InvenTreeUserSetting.SETTINGS
# check if key exist and delete if not
for setting in db_settings:
if setting.key not in model_settings:
setting.delete()
print(f"deleted user setting '{setting.key}'")
logger.info(f"deleted user setting '{setting.key}'")
print("checked all settings")
logger.info("checked all settings")

View File

@ -17,7 +17,6 @@ import os
import random
import socket
import string
import shutil
import sys
from datetime import datetime
@ -28,30 +27,12 @@ from django.utils.translation import gettext_lazy as _
from django.contrib.messages import constants as messages
import django.conf.locale
from .config import get_base_dir, get_config_file, get_plugin_file, get_setting
def _is_true(x):
# Shortcut function to determine if a value "looks" like a boolean
return str(x).lower() in ['1', 'y', 'yes', 't', 'true']
def get_setting(environment_var, backup_val, default_value=None):
"""
Helper function for retrieving a configuration setting value
- First preference is to look for the environment variable
- Second preference is to look for the value of the settings file
- Third preference is the default value
"""
val = os.getenv(environment_var)
if val is not None:
return val
if backup_val is not None:
return backup_val
return default_value
return str(x).strip().lower() in ['1', 'y', 'yes', 't', 'true']
# Determine if we are running in "test" mode e.g. "manage.py test"
@ -61,27 +42,9 @@ TESTING = 'test' in sys.argv
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE_DIR = get_base_dir()
# Specify where the "config file" is located.
# By default, this is 'config.yaml'
cfg_filename = os.getenv('INVENTREE_CONFIG_FILE')
if cfg_filename:
cfg_filename = cfg_filename.strip()
cfg_filename = os.path.abspath(cfg_filename)
else:
# Config file is *not* specified - use the default
cfg_filename = os.path.join(BASE_DIR, 'config.yaml')
if not os.path.exists(cfg_filename):
print("InvenTree configuration file 'config.yaml' not found - creating default file")
cfg_template = os.path.join(BASE_DIR, "config_template.yaml")
shutil.copyfile(cfg_template, cfg_filename)
print(f"Created config file {cfg_filename}")
cfg_filename = get_config_file()
with open(cfg_filename, 'r') as cfg:
CONFIG = yaml.safe_load(cfg)
@ -89,6 +52,8 @@ with open(cfg_filename, 'r') as cfg:
# We will place any config files in the same directory as the config file
config_dir = os.path.dirname(cfg_filename)
PLUGIN_FILE = get_plugin_file()
# Default action is to run the system in Debug mode
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = _is_true(get_setting(
@ -908,8 +873,7 @@ MARKDOWNIFY_BLEACH = False
# Maintenance mode
MAINTENANCE_MODE_RETRY_AFTER = 60
# Plugins
# Plugin Directories (local plugins will be loaded from these directories)
PLUGIN_DIRS = ['plugin.builtin', ]
if not TESTING:

View File

@ -237,25 +237,41 @@ class TestSerialNumberExtraction(TestCase):
e = helpers.extract_serial_numbers
sn = e("1-5", 5)
self.assertEqual(len(sn), 5)
sn = e("1-5", 5, 1)
self.assertEqual(len(sn), 5, 1)
for i in range(1, 6):
self.assertIn(i, sn)
sn = e("1, 2, 3, 4, 5", 5)
sn = e("1, 2, 3, 4, 5", 5, 1)
self.assertEqual(len(sn), 5)
sn = e("1-5, 10-15", 11)
sn = e("1-5, 10-15", 11, 1)
self.assertIn(3, sn)
self.assertIn(13, sn)
sn = e("1+", 10)
sn = e("1+", 10, 1)
self.assertEqual(len(sn), 10)
self.assertEqual(sn, [_ for _ in range(1, 11)])
sn = e("4, 1+2", 4)
sn = e("4, 1+2", 4, 1)
self.assertEqual(len(sn), 4)
self.assertEqual(sn, ["4", 1, 2, 3])
self.assertEqual(sn, [4, 1, 2, 3])
sn = e("~", 1, 1)
self.assertEqual(len(sn), 1)
self.assertEqual(sn, [1])
sn = e("~", 1, 3)
self.assertEqual(len(sn), 1)
self.assertEqual(sn, [3])
sn = e("~+", 2, 5)
self.assertEqual(len(sn), 2)
self.assertEqual(sn, [5, 6])
sn = e("~+3", 4, 5)
self.assertEqual(len(sn), 4)
self.assertEqual(sn, [5, 6, 7, 8])
def test_failures(self):
@ -263,26 +279,45 @@ class TestSerialNumberExtraction(TestCase):
# Test duplicates
with self.assertRaises(ValidationError):
e("1,2,3,3,3", 5)
e("1,2,3,3,3", 5, 1)
# Test invalid length
with self.assertRaises(ValidationError):
e("1,2,3", 5)
e("1,2,3", 5, 1)
# Test empty string
with self.assertRaises(ValidationError):
e(", , ,", 0)
e(", , ,", 0, 1)
# Test incorrect sign in group
with self.assertRaises(ValidationError):
e("10-2", 8)
e("10-2", 8, 1)
# Test invalid group
with self.assertRaises(ValidationError):
e("1-5-10", 10)
e("1-5-10", 10, 1)
with self.assertRaises(ValidationError):
e("10, a, 7-70j", 4)
e("10, a, 7-70j", 4, 1)
def test_combinations(self):
e = helpers.extract_serial_numbers
sn = e("1 3-5 9+2", 7, 1)
self.assertEqual(len(sn), 7)
self.assertEqual(sn, [1, 3, 4, 5, 9, 10, 11])
sn = e("1,3-5,9+2", 7, 1)
self.assertEqual(len(sn), 7)
self.assertEqual(sn, [1, 3, 4, 5, 9, 10, 11])
sn = e("~+2", 3, 14)
self.assertEqual(len(sn), 3)
self.assertEqual(sn, [14, 15, 16])
sn = e("~+", 2, 14)
self.assertEqual(len(sn), 2)
self.assertEqual(sn, [14, 15])
class TestVersionNumber(TestCase):

View File

@ -12,11 +12,15 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 22
INVENTREE_API_VERSION = 23
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v23 -> 2022-02-02
- Adds API endpoints for managing plugin classes
- Adds API endpoints for managing plugin settings
v22 -> 2021-12-20
- Adds API endpoint to "merge" multiple stock items

View File

@ -18,8 +18,7 @@ from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem, BuildOrderAttachment
from .serializers import BuildAttachmentSerializer, BuildCompleteSerializer, BuildSerializer, BuildItemSerializer
from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
import build.serializers
from users.models import Owner
@ -80,7 +79,7 @@ class BuildList(generics.ListCreateAPIView):
"""
queryset = Build.objects.all()
serializer_class = BuildSerializer
serializer_class = build.serializers.BuildSerializer
filterset_class = BuildFilter
filter_backends = [
@ -119,7 +118,7 @@ class BuildList(generics.ListCreateAPIView):
queryset = super().get_queryset().select_related('part')
queryset = BuildSerializer.annotate_queryset(queryset)
queryset = build.serializers.BuildSerializer.annotate_queryset(queryset)
return queryset
@ -203,7 +202,7 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
""" API endpoint for detail view of a Build object """
queryset = Build.objects.all()
serializer_class = BuildSerializer
serializer_class = build.serializers.BuildSerializer
class BuildUnallocate(generics.CreateAPIView):
@ -217,7 +216,7 @@ class BuildUnallocate(generics.CreateAPIView):
queryset = Build.objects.none()
serializer_class = BuildUnallocationSerializer
serializer_class = build.serializers.BuildUnallocationSerializer
def get_serializer_context(self):
@ -233,14 +232,36 @@ class BuildUnallocate(generics.CreateAPIView):
return ctx
class BuildComplete(generics.CreateAPIView):
class BuildOutputComplete(generics.CreateAPIView):
"""
API endpoint for completing build outputs
"""
queryset = Build.objects.none()
serializer_class = BuildCompleteSerializer
serializer_class = build.serializers.BuildOutputCompleteSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['request'] = self.request
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
class BuildFinish(generics.CreateAPIView):
"""
API endpoint for marking a build as finished (completed)
"""
queryset = Build.objects.none()
serializer_class = build.serializers.BuildCompleteSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
@ -269,7 +290,7 @@ class BuildAllocate(generics.CreateAPIView):
queryset = Build.objects.none()
serializer_class = BuildAllocationSerializer
serializer_class = build.serializers.BuildAllocationSerializer
def get_serializer_context(self):
"""
@ -294,7 +315,7 @@ class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView):
"""
queryset = BuildItem.objects.all()
serializer_class = BuildItemSerializer
serializer_class = build.serializers.BuildItemSerializer
class BuildItemList(generics.ListCreateAPIView):
@ -304,7 +325,7 @@ class BuildItemList(generics.ListCreateAPIView):
- POST: Create a new BuildItem object
"""
serializer_class = BuildItemSerializer
serializer_class = build.serializers.BuildItemSerializer
def get_serializer(self, *args, **kwargs):
@ -373,7 +394,7 @@ class BuildAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
"""
queryset = BuildOrderAttachment.objects.all()
serializer_class = BuildAttachmentSerializer
serializer_class = build.serializers.BuildAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
@ -390,7 +411,7 @@ class BuildAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix
"""
queryset = BuildOrderAttachment.objects.all()
serializer_class = BuildAttachmentSerializer
serializer_class = build.serializers.BuildAttachmentSerializer
build_api_urls = [
@ -410,7 +431,8 @@ build_api_urls = [
# Build Detail
url(r'^(?P<pk>\d+)/', include([
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
url(r'^complete/', BuildComplete.as_view(), name='api-build-complete'),
url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
])),

View File

@ -83,24 +83,6 @@ class BuildOutputDeleteForm(HelperForm):
]
class CompleteBuildForm(HelperForm):
"""
Form for marking a build as complete
"""
confirm = forms.BooleanField(
required=True,
label=_('Confirm'),
help_text=_('Mark build as complete'),
)
class Meta:
model = Build
fields = [
'confirm',
]
class CancelBuildForm(HelperForm):
""" Form for cancelling a build """

View File

@ -555,7 +555,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
if self.incomplete_count > 0:
return False
if self.completed < self.quantity:
if self.remaining > 0:
return False
if not self.areUntrackedPartsFullyAllocated():

View File

@ -165,7 +165,7 @@ class BuildOutputSerializer(serializers.Serializer):
]
class BuildCompleteSerializer(serializers.Serializer):
class BuildOutputCompleteSerializer(serializers.Serializer):
"""
DRF serializer for completing one or more build outputs
"""
@ -240,6 +240,47 @@ class BuildCompleteSerializer(serializers.Serializer):
)
class BuildCompleteSerializer(serializers.Serializer):
"""
DRF serializer for marking a BuildOrder as complete
"""
accept_unallocated = serializers.BooleanField(
label=_('Accept Unallocated'),
help_text=_('Accept that stock items have not been fully allocated to this build order'),
)
def validate_accept_unallocated(self, value):
build = self.context['build']
if not build.areUntrackedPartsFullyAllocated() and not value:
raise ValidationError(_('Required stock has not been fully allocated'))
return value
accept_incomplete = serializers.BooleanField(
label=_('Accept Incomplete'),
help_text=_('Accept that the required number of build outputs have not been completed'),
)
def validate_accept_incomplete(self, value):
build = self.context['build']
if build.remaining > 0 and not value:
raise ValidationError(_('Required build quantity has not been completed'))
return value
def save(self):
request = self.context['request']
build = self.context['build']
build.complete_build(request.user)
class BuildUnallocationSerializer(serializers.Serializer):
"""
DRF serializer for unallocating stock from a BuildOrder

View File

@ -224,13 +224,11 @@ src="{% static 'img/blank_image.png' %}"
'{% trans "Build Order cannot be completed as incomplete build outputs remain" %}'
);
{% else %}
launchModalForm(
"{% url 'build-complete' build.id %}",
{
reload: true,
submit_text: '{% trans "Complete Build" %}',
}
);
completeBuildOrder({{ build.pk }}, {
allocated: {% if build.areUntrackedPartsFullyAllocated %}true{% else %}false{% endif %},
completed: {% if build.remaining == 0 %}true{% else %}false{% endif %},
});
{% endif %}
});

View File

@ -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 %}

View File

@ -49,7 +49,7 @@ class BuildCompleteTest(BuildAPITest):
self.build = Build.objects.get(pk=1)
self.url = reverse('api-build-complete', kwargs={'pk': self.build.pk})
self.url = reverse('api-build-output-complete', kwargs={'pk': self.build.pk})
def test_invalid(self):
"""
@ -58,7 +58,7 @@ class BuildCompleteTest(BuildAPITest):
# Test with an invalid build ID
self.post(
reverse('api-build-complete', kwargs={'pk': 99999}),
reverse('api-build-output-complete', kwargs={'pk': 99999}),
{},
expected_code=400
)

View File

@ -11,7 +11,6 @@ build_detail_urls = [
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
]

View File

@ -109,7 +109,7 @@ class BuildOutputCreate(AjaxUpdateView):
# Check that the serial numbers are valid
if serials:
try:
extracted = extract_serial_numbers(serials, quantity)
extracted = extract_serial_numbers(serials, quantity, build.part.getLatestSerialNumberInt())
if extracted:
# Check for conflicting serial numbers
@ -143,7 +143,7 @@ class BuildOutputCreate(AjaxUpdateView):
serials = data.get('serial_numbers', None)
if serials:
serial_numbers = extract_serial_numbers(serials, quantity)
serial_numbers = extract_serial_numbers(serials, quantity, build.part.getLatestSerialNumberInt())
else:
serial_numbers = None
@ -246,39 +246,6 @@ class BuildOutputDelete(AjaxUpdateView):
}
class BuildComplete(AjaxUpdateView):
"""
View to mark the build as complete.
Requirements:
- There can be no outstanding build outputs
- The "completed" value must meet or exceed the "quantity" value
"""
model = Build
form_class = forms.CompleteBuildForm
ajax_form_title = _('Complete Build Order')
ajax_template_name = 'build/complete.html'
def validate(self, build, form, **kwargs):
if build.incomplete_count > 0:
form.add_error(None, _('Build order cannot be completed - incomplete outputs remain'))
def save(self, build, form, **kwargs):
"""
Perform the build completion step
"""
build.complete_build(self.request.user)
def get_data(self):
return {
'success': _('Completed build order')
}
class BuildDetail(InvenTreeRoleMixin, DetailView):
"""
Detail view of a single Build object.

View File

@ -53,7 +53,7 @@ class BaseInvenTreeSetting(models.Model):
single values (e.g. one-off settings values).
"""
GLOBAL_SETTINGS = {}
SETTINGS = {}
class Meta:
abstract = True
@ -65,7 +65,7 @@ class BaseInvenTreeSetting(models.Model):
self.key = str(self.key).upper()
self.clean()
self.clean(**kwargs)
self.validate_unique()
super().save()
@ -82,6 +82,7 @@ class BaseInvenTreeSetting(models.Model):
results = cls.objects.all()
# Optionally filter by user
if user is not None:
results = results.filter(user=user)
@ -93,13 +94,13 @@ class BaseInvenTreeSetting(models.Model):
settings[setting.key.upper()] = setting.value
# Specify any "default" values which are not in the database
for key in cls.GLOBAL_SETTINGS.keys():
for key in cls.SETTINGS.keys():
if key.upper() not in settings:
settings[key.upper()] = cls.get_setting_default(key)
if exclude_hidden:
hidden = cls.GLOBAL_SETTINGS[key].get('hidden', False)
hidden = cls.SETTINGS[key].get('hidden', False)
if hidden:
# Remove hidden items
@ -123,98 +124,92 @@ class BaseInvenTreeSetting(models.Model):
return settings
@classmethod
def get_setting_name(cls, key):
def get_setting_definition(cls, key, **kwargs):
"""
Return the 'definition' of a particular settings value, as a dict object.
- The 'settings' dict can be passed as a kwarg
- If not passed, look for cls.SETTINGS
- Returns an empty dict if the key is not found
"""
settings = kwargs.get('settings', cls.SETTINGS)
key = str(key).strip().upper()
if settings is not None and key in settings:
return settings[key]
else:
return {}
@classmethod
def get_setting_name(cls, key, **kwargs):
"""
Return the name of a particular setting.
If it does not exist, return an empty string.
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('name', '')
else:
return ''
setting = cls.get_setting_definition(key, **kwargs)
return setting.get('name', '')
@classmethod
def get_setting_description(cls, key):
def get_setting_description(cls, key, **kwargs):
"""
Return the description for a particular setting.
If it does not exist, return an empty string.
"""
key = str(key).strip().upper()
setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('description', '')
else:
return ''
return setting.get('description', '')
@classmethod
def get_setting_units(cls, key):
def get_setting_units(cls, key, **kwargs):
"""
Return the units for a particular setting.
If it does not exist, return an empty string.
"""
key = str(key).strip().upper()
setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('units', '')
else:
return ''
return setting.get('units', '')
@classmethod
def get_setting_validator(cls, key):
def get_setting_validator(cls, key, **kwargs):
"""
Return the validator for a particular setting.
If it does not exist, return None
"""
key = str(key).strip().upper()
setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('validator', None)
else:
return None
return setting.get('validator', None)
@classmethod
def get_setting_default(cls, key):
def get_setting_default(cls, key, **kwargs):
"""
Return the default value for a particular setting.
If it does not exist, return an empty string
"""
key = str(key).strip().upper()
setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('default', '')
else:
return ''
return setting.get('default', '')
@classmethod
def get_setting_choices(cls, key):
def get_setting_choices(cls, key, **kwargs):
"""
Return the validator choices available for a particular setting.
"""
key = str(key).strip().upper()
setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
choices = setting.get('choices', None)
else:
choices = None
choices = setting.get('choices', None)
if callable(choices):
# Evaluate the function (we expect it will return a list of tuples...)
@ -237,17 +232,40 @@ class BaseInvenTreeSetting(models.Model):
key = str(key).strip().upper()
settings = cls.objects.all()
# Filter by user
user = kwargs.get('user', None)
if user is not None:
settings = settings.filter(user=user)
try:
setting = cls.objects.filter(**cls.get_filters(key, **kwargs)).first()
setting = settings.filter(**cls.get_filters(key, **kwargs)).first()
except (ValueError, cls.DoesNotExist):
setting = None
except (IntegrityError, OperationalError):
setting = None
plugin = kwargs.pop('plugin', None)
if plugin:
from plugin import InvenTreePlugin
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
kwargs['plugin'] = plugin
# Setting does not exist! (Try to create it)
if not setting:
setting = cls(key=key, value=cls.get_setting_default(key), **kwargs)
# Attempt to create a new settings object
setting = cls(
key=key,
value=cls.get_setting_default(key, **kwargs),
**kwargs
)
try:
# Wrap this statement in "atomic", so it can be rolled back if it fails
@ -259,21 +277,6 @@ class BaseInvenTreeSetting(models.Model):
return setting
@classmethod
def get_setting_pk(cls, key):
"""
Return the primary-key value for a given setting.
If the setting does not exist, return None
"""
setting = cls.get_setting_object(cls)
if setting:
return setting.pk
else:
return None
@classmethod
def get_setting(cls, key, backup_value=None, **kwargs):
"""
@ -283,18 +286,19 @@ class BaseInvenTreeSetting(models.Model):
# If no backup value is specified, atttempt to retrieve a "default" value
if backup_value is None:
backup_value = cls.get_setting_default(key)
backup_value = cls.get_setting_default(key, **kwargs)
setting = cls.get_setting_object(key, **kwargs)
if setting:
value = setting.value
# If the particular setting is defined as a boolean, cast the value to a boolean
if setting.is_bool():
# Cast to boolean if necessary
if setting.is_bool(**kwargs):
value = InvenTree.helpers.str2bool(value)
if setting.is_int():
# Cast to integer if necessary
if setting.is_int(**kwargs):
try:
value = int(value)
except (ValueError, TypeError):
@ -357,7 +361,7 @@ class BaseInvenTreeSetting(models.Model):
def units(self):
return self.__class__.get_setting_units(self.key)
def clean(self):
def clean(self, **kwargs):
"""
If a validator (or multiple validators) are defined for a particular setting key,
run them against the 'value' field.
@ -365,25 +369,16 @@ class BaseInvenTreeSetting(models.Model):
super().clean()
validator = self.__class__.get_setting_validator(self.key)
validator = self.__class__.get_setting_validator(self.key, **kwargs)
if self.is_bool():
self.value = InvenTree.helpers.str2bool(self.value)
if self.is_int():
try:
self.value = int(self.value)
except (ValueError):
raise ValidationError(_('Must be an integer value'))
if validator is not None:
self.run_validator(validator)
options = self.valid_options()
if options and self.value not in options:
raise ValidationError(_("Chosen value is not a valid option"))
if validator is not None:
self.run_validator(validator)
def run_validator(self, validator):
"""
Run a validator against the 'value' field for this InvenTreeSetting object.
@ -395,7 +390,7 @@ class BaseInvenTreeSetting(models.Model):
value = self.value
# Boolean validator
if self.is_bool():
if validator is bool:
# Value must "look like" a boolean value
if InvenTree.helpers.is_bool(value):
# Coerce into either "True" or "False"
@ -406,7 +401,7 @@ class BaseInvenTreeSetting(models.Model):
})
# Integer validator
if self.is_int():
if validator is int:
try:
# Coerce into an integer value
@ -459,12 +454,12 @@ class BaseInvenTreeSetting(models.Model):
return [opt[0] for opt in choices]
def is_bool(self):
def is_bool(self, **kwargs):
"""
Check if this setting is required to be a boolean value
"""
validator = self.__class__.get_setting_validator(self.key)
validator = self.__class__.get_setting_validator(self.key, **kwargs)
return self.__class__.validator_is_bool(validator)
@ -477,15 +472,15 @@ class BaseInvenTreeSetting(models.Model):
return InvenTree.helpers.str2bool(self.value)
def setting_type(self):
def setting_type(self, **kwargs):
"""
Return the field type identifier for this setting object
"""
if self.is_bool():
if self.is_bool(**kwargs):
return 'boolean'
elif self.is_int():
elif self.is_int(**kwargs):
return 'integer'
else:
@ -504,12 +499,12 @@ class BaseInvenTreeSetting(models.Model):
return False
def is_int(self):
def is_int(self, **kwargs):
"""
Check if the setting is required to be an integer value:
"""
validator = self.__class__.get_setting_validator(self.key)
validator = self.__class__.get_setting_validator(self.key, **kwargs)
return self.__class__.validator_is_int(validator)
@ -541,21 +536,20 @@ class BaseInvenTreeSetting(models.Model):
return value
@classmethod
def is_protected(cls, key):
def is_protected(cls, key, **kwargs):
"""
Check if the setting value is protected
"""
key = str(key).strip().upper()
setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS:
return cls.GLOBAL_SETTINGS[key].get('protected', False)
else:
return False
return setting.get('protected', False)
def settings_group_options():
"""build up group tuple for settings based on gour choices"""
"""
Build up group tuple for settings based on your choices
"""
return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]]
@ -577,7 +571,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
super().save()
if self.requires_restart():
InvenTreeSetting.set_setting('SERVER_REQUIRES_RESTART', True, None)
InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None)
"""
Dict of all global settings values:
@ -595,7 +589,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
The keys must be upper-case
"""
GLOBAL_SETTINGS = {
SETTINGS = {
'SERVER_RESTART_REQUIRED': {
'name': _('Restart required'),
@ -977,13 +971,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
'requires_restart': True,
},
'ENABLE_PLUGINS_GLOBALSETTING': {
'name': _('Enable global setting integration'),
'description': _('Enable plugins to integrate into inventree global settings'),
'default': False,
'validator': bool,
'requires_restart': True,
},
'ENABLE_PLUGINS_APP': {
'name': _('Enable app integration'),
'description': _('Enable plugins to add apps'),
@ -991,6 +978,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
'requires_restart': True,
},
'ENABLE_PLUGINS_SCHEDULE': {
'name': _('Enable schedule integration'),
'description': _('Enable plugins to run scheduled tasks'),
'default': False,
'validator': bool,
'requires_restart': True,
}
}
class Meta:
@ -1017,7 +1011,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
Return True if this setting requires a server restart after changing
"""
options = InvenTreeSetting.GLOBAL_SETTINGS.get(self.key, None)
options = InvenTreeSetting.SETTINGS.get(self.key, None)
if options:
return options.get('requires_restart', False)
@ -1030,7 +1024,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
An InvenTreeSetting object with a usercontext
"""
GLOBAL_SETTINGS = {
SETTINGS = {
'HOMEPAGE_PART_STARRED': {
'name': _('Show subscribed parts'),
'description': _('Show subscribed parts on the homepage'),

View File

@ -49,9 +49,9 @@ class SettingsTest(TestCase):
- Ensure that every global setting has a description.
"""
for key in InvenTreeSetting.GLOBAL_SETTINGS.keys():
for key in InvenTreeSetting.SETTINGS.keys():
setting = InvenTreeSetting.GLOBAL_SETTINGS[key]
setting = InvenTreeSetting.SETTINGS[key]
name = setting.get('name', None)
@ -64,14 +64,14 @@ class SettingsTest(TestCase):
raise ValueError(f'Missing GLOBAL_SETTING description for {key}')
if not key == key.upper():
raise ValueError(f"GLOBAL_SETTINGS key '{key}' is not uppercase")
raise ValueError(f"SETTINGS key '{key}' is not uppercase")
def test_defaults(self):
"""
Populate the settings with default values
"""
for key in InvenTreeSetting.GLOBAL_SETTINGS.keys():
for key in InvenTreeSetting.SETTINGS.keys():
value = InvenTreeSetting.get_setting_default(key)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -861,7 +861,7 @@ class SOSerialAllocationSerializer(serializers.Serializer):
part = line_item.part
try:
data['serials'] = extract_serial_numbers(serial_numbers, quantity)
data['serials'] = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt())
except DjangoValidationError as e:
raise ValidationError({
'serial_numbers': e.messages,

View File

@ -594,6 +594,26 @@ class Part(MPTTModel):
# No serial numbers found
return None
def getLatestSerialNumberInt(self):
"""
Return the "latest" serial number for this Part as a integer.
If it is not an integer the result is 0
"""
latest = self.getLatestSerialNumber()
# No serial number = > 0
if latest is None:
latest = 0
# Attempt to turn into an integer and return
try:
latest = int(latest)
return latest
except:
# not an integer so 0
return 0
def getSerialNumberString(self, quantity=1):
"""
Return a formatted string representing the next available serial numbers,

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
""" This module provides template tags for extra functionality
"""
This module provides template tags for extra functionality,
over and above the built-in Django tags.
"""
@ -22,6 +23,8 @@ import InvenTree.helpers
from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting
from common.settings import currency_code_default
from plugin.models import PluginSetting
register = template.Library()
@ -223,8 +226,16 @@ def setting_object(key, *args, **kwargs):
if a user-setting was requested return that
"""
if 'plugin' in kwargs:
# Note, 'plugin' is an instance of an InvenTreePlugin class
plugin = kwargs['plugin']
return PluginSetting.get_setting_object(key, plugin=plugin)
if 'user' in kwargs:
return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'])
return InvenTreeSetting.get_setting_object(key)

View File

@ -1,7 +1,11 @@
from .registry import plugins as plugin_reg
from .registry import plugin_registry
from .plugin import InvenTreePlugin
from .integration import IntegrationPluginBase
from .action import ActionPlugin
__all__ = [
'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin',
'ActionPlugin',
'IntegrationPluginBase',
'InvenTreePlugin',
'plugin_registry',
]

View File

@ -4,43 +4,70 @@ from __future__ import unicode_literals
from django.contrib import admin
import plugin.models as models
from plugin import plugin_reg
import plugin.registry as registry
def plugin_update(queryset, new_status: bool):
"""general function for bulk changing plugins"""
"""
General function for bulk changing plugins
"""
apps_changed = False
# run through all plugins in the queryset as the save method needs to be overridden
# Run through all plugins in the queryset as the save method needs to be overridden
for plugin in queryset:
if plugin.active is not new_status:
plugin.active = new_status
plugin.save(no_reload=True)
apps_changed = True
# reload plugins if they changed
# Reload plugins if they changed
if apps_changed:
plugin_reg.reload_plugins()
registry.plugin_registry.reload_plugins()
@admin.action(description='Activate plugin(s)')
def plugin_activate(modeladmin, request, queryset):
"""activate a set of plugins"""
"""
Activate a set of plugins
"""
plugin_update(queryset, True)
@admin.action(description='Deactivate plugin(s)')
def plugin_deactivate(modeladmin, request, queryset):
"""deactivate a set of plugins"""
"""
Deactivate a set of plugins
"""
plugin_update(queryset, False)
class PluginSettingInline(admin.TabularInline):
"""
Inline admin class for PluginSetting
"""
model = models.PluginSetting
read_only_fields = [
'key',
]
def has_add_permission(self, request, obj):
return False
class PluginConfigAdmin(admin.ModelAdmin):
"""Custom admin with restricted id fields"""
"""
Custom admin with restricted id fields
"""
readonly_fields = ["key", "name", ]
list_display = ['active', '__str__', 'key', 'name', ]
list_display = ['name', 'key', '__str__', 'active', ]
list_filter = ['active']
actions = [plugin_activate, plugin_deactivate, ]
inlines = [PluginSettingInline, ]
admin.site.register(models.PluginConfig, PluginConfigAdmin)

View File

@ -11,7 +11,8 @@ from rest_framework import generics
from rest_framework import status
from rest_framework.response import Response
from plugin.models import PluginConfig
from common.api import GlobalSettingsPermissions
from plugin.models import PluginConfig, PluginSetting
import plugin.serializers as PluginSerializers
@ -76,7 +77,46 @@ class PluginInstall(generics.CreateAPIView):
return serializer.save()
class PluginSettingList(generics.ListAPIView):
"""
List endpoint for all plugin related settings.
- read only
- only accessible by staff users
"""
queryset = PluginSetting.objects.all()
serializer_class = PluginSerializers.PluginSettingSerializer
permission_classes = [
GlobalSettingsPermissions,
]
class PluginSettingDetail(generics.RetrieveUpdateAPIView):
"""
Detail endpoint for a plugin-specific setting.
Note that these cannot be created or deleted via the API
"""
queryset = PluginSetting.objects.all()
serializer_class = PluginSerializers.PluginSettingSerializer
# Staff permission required
permission_classes = [
GlobalSettingsPermissions,
]
plugin_api_urls = [
# Plugin settings URLs
url(r'^settings/', include([
url(r'^(?P<pk>\d+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'),
url(r'^.*$', PluginSettingList.as_view(), name='api-plugin-setting-list'),
])),
# Detail views for a single PluginConfig item
url(r'^(?P<pk>\d+)/', include([
url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),

View File

@ -4,17 +4,17 @@ from __future__ import unicode_literals
from django.apps import AppConfig
from maintenance_mode.core import set_maintenance_mode
from plugin.registry import plugins
from plugin import plugin_registry
class PluginAppConfig(AppConfig):
name = 'plugin'
def ready(self):
if not plugins.is_loading:
if not plugin_registry.is_loading:
# this is the first startup
plugins.collect_plugins()
plugins.load_plugins()
plugin_registry.collect_plugins()
plugin_registry.load_plugins()
# drop out of maintenance
# makes sure we did not have an error in reloading and maintenance is still active

View File

@ -1,68 +1,193 @@
"""default mixins for IntegrationMixins"""
"""
Plugin mixin classes
"""
import logging
import json
import requests
from django.conf.urls import url, include
from django.db.utils import OperationalError, ProgrammingError
from plugin.models import PluginConfig, PluginSetting
from plugin.urls import PLUGIN_BASE
class GlobalSettingsMixin:
"""Mixin that enables global settings for the plugin"""
logger = logging.getLogger('inventree')
class SettingsMixin:
"""
Mixin that enables global settings for the plugin
"""
class MixinMeta:
"""meta options for this mixin"""
MIXIN_NAME = 'Global settings'
MIXIN_NAME = 'Settings'
def __init__(self):
super().__init__()
self.add_mixin('globalsettings', 'has_globalsettings', __class__)
self.globalsettings = self.setup_globalsettings()
def setup_globalsettings(self):
"""
setup global settings for this plugin
"""
return getattr(self, 'GLOBALSETTINGS', None)
self.add_mixin('settings', 'has_settings', __class__)
self.settings = getattr(self, 'SETTINGS', {})
@property
def has_globalsettings(self):
def has_settings(self):
"""
does this plugin use custom global settings
Does this plugin use custom global settings
"""
return bool(self.globalsettings)
return bool(self.settings)
def get_setting(self, key):
"""
Return the 'value' of the setting associated with this plugin
"""
return PluginSetting.get_setting(key, plugin=self)
def set_setting(self, key, value, user=None):
"""
Set plugin setting value by key
"""
try:
plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
except (OperationalError, ProgrammingError):
plugin = None
if not plugin:
# Cannot find associated plugin model, return
return
PluginSetting.set_setting(key, value, user, plugin=plugin)
class ScheduleMixin:
"""
Mixin that provides support for scheduled tasks.
Implementing classes must provide a dict object called SCHEDULED_TASKS,
which provides information on the tasks to be scheduled.
SCHEDULED_TASKS = {
# Name of the task (will be prepended with the plugin name)
'test_server': {
'func': 'myplugin.tasks.test_server', # Python function to call (no arguments!)
'schedule': "I", # Schedule type (see django_q.Schedule)
'minutes': 30, # Number of minutes (only if schedule type = Minutes)
'repeats': 5, # Number of repeats (leave blank for 'forever')
}
}
Note: 'schedule' parameter must be one of ['I', 'H', 'D', 'W', 'M', 'Q', 'Y']
"""
ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y']
SCHEDULED_TASKS = {}
class MixinMeta:
MIXIN_NAME = 'Schedule'
def __init__(self):
super().__init__()
self.add_mixin('schedule', 'has_scheduled_tasks', __class__)
self.scheduled_tasks = getattr(self, 'SCHEDULED_TASKS', {})
self.validate_scheduled_tasks()
@property
def globalsettingspatterns(self):
"""
get patterns for InvenTreeSetting defintion
"""
if self.has_globalsettings:
return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.globalsettings.items()}
return None
def has_scheduled_tasks(self):
return bool(self.scheduled_tasks)
def _globalsetting_name(self, key):
"""get global name of setting"""
return f'PLUGIN_{self.slug.upper()}_{key}'
def validate_scheduled_tasks(self):
"""
Check that the provided scheduled tasks are valid
"""
def get_globalsetting(self, key):
"""
get plugin global setting by key
"""
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting(self._globalsetting_name(key))
if not self.has_scheduled_tasks:
raise ValueError("SCHEDULED_TASKS not defined")
def set_globalsetting(self, key, value, user):
for key, task in self.scheduled_tasks.items():
if 'func' not in task:
raise ValueError(f"Task '{key}' is missing 'func' parameter")
if 'schedule' not in task:
raise ValueError(f"Task '{key}' is missing 'schedule' parameter")
schedule = task['schedule'].upper().strip()
if schedule not in self.ALLOWABLE_SCHEDULE_TYPES:
raise ValueError(f"Task '{key}': Schedule '{schedule}' is not a valid option")
# If 'minutes' is selected, it must be provided!
if schedule == 'I' and 'minutes' not in task:
raise ValueError(f"Task '{key}' is missing 'minutes' parameter")
def get_task_name(self, key):
# Generate a 'unique' task name
slug = self.plugin_slug()
return f"plugin.{slug}.{key}"
def get_task_names(self):
# Returns a list of all task names associated with this plugin instance
return [self.get_task_name(key) for key in self.scheduled_tasks.keys()]
def register_tasks(self):
"""
set plugin global setting by key
Register the tasks with the database
"""
from common.models import InvenTreeSetting
return InvenTreeSetting.set_setting(self._globalsetting_name(key), value, user)
try:
from django_q.models import Schedule
for key, task in self.scheduled_tasks.items():
task_name = self.get_task_name(key)
# If a matching scheduled task does not exist, create it!
if not Schedule.objects.filter(name=task_name).exists():
logger.info(f"Adding scheduled task '{task_name}'")
Schedule.objects.create(
name=task_name,
func=task['func'],
schedule_type=task['schedule'],
minutes=task.get('minutes', None),
repeats=task.get('repeats', -1),
)
except (ProgrammingError, OperationalError):
# Database might not yet be ready
logger.warning("register_tasks failed, database not ready")
def unregister_tasks(self):
"""
Deregister the tasks with the database
"""
try:
from django_q.models import Schedule
for key, task in self.scheduled_tasks.items():
task_name = self.get_task_name(key)
try:
scheduled_task = Schedule.objects.get(name=task_name)
scheduled_task.delete()
except Schedule.DoesNotExist:
pass
except (ProgrammingError, OperationalError):
# Database might not yet be ready
logger.warning("unregister_tasks failed, database not ready")
class UrlsMixin:
"""Mixin that enables urls for the plugin"""
"""
Mixin that enables custom URLs for the plugin
"""
class MixinMeta:
"""meta options for this mixin"""
MIXIN_NAME = 'URLs'
def __init__(self):
@ -108,12 +233,17 @@ class UrlsMixin:
class NavigationMixin:
"""Mixin that enables adding navigation links with the plugin"""
"""
Mixin that enables custom navigation links with the plugin
"""
NAVIGATION_TAB_NAME = None
NAVIGATION_TAB_ICON = "fas fa-question"
class MixinMeta:
"""meta options for this mixin"""
"""
meta options for this mixin
"""
MIXIN_NAME = 'Navigation Links'
def __init__(self):
@ -155,7 +285,10 @@ class NavigationMixin:
class AppMixin:
"""Mixin that enables full django app functions for a plugin"""
"""
Mixin that enables full django app functions for a plugin
"""
class MixinMeta:
"""meta options for this mixin"""
MIXIN_NAME = 'App registration'

View File

@ -10,14 +10,14 @@ from django.conf import settings
# region logging / errors
def log_plugin_error(error, reference: str = 'general'):
from plugin import plugin_reg
from plugin import plugin_registry
# make sure the registry is set up
if reference not in plugin_reg.errors:
plugin_reg.errors[reference] = []
if reference not in plugin_registry.errors:
plugin_registry.errors[reference] = []
# add error to stack
plugin_reg.errors[reference].append(error)
plugin_registry.errors[reference].append(error)
class IntegrationPluginError(Exception):

View File

@ -9,7 +9,6 @@ import pathlib
from django.urls.base import reverse
from django.conf import settings
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
import plugin.plugin as plugin
@ -20,19 +19,27 @@ logger = logging.getLogger("inventree")
class MixinBase:
"""general base for mixins"""
"""
General base for mixins
"""
def __init__(self) -> None:
self._mixinreg = {}
self._mixins = {}
def add_mixin(self, key: str, fnc_enabled=True, cls=None):
"""add a mixin to the plugins registry"""
"""
Add a mixin to the plugins registry
"""
self._mixins[key] = fnc_enabled
self.setup_mixin(key, cls=cls)
def setup_mixin(self, key, cls=None):
"""define mixin details for the current mixin -> provides meta details for all active mixins"""
"""
Define mixin details for the current mixin -> provides meta details for all active mixins
"""
# get human name
human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
@ -44,7 +51,10 @@ class MixinBase:
@property
def registered_mixins(self, with_base: bool = False):
"""get all registered mixins for the plugin"""
"""
Get all registered mixins for the plugin
"""
mixins = getattr(self, '_mixinreg', None)
if mixins:
# filter out base
@ -59,8 +69,6 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
"""
The IntegrationPluginBase class is used to integrate with 3rd party software
"""
PLUGIN_SLUG = None
PLUGIN_TITLE = None
AUTHOR = None
DESCRIPTION = None
@ -84,11 +92,11 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
# region properties
@property
def slug(self):
"""slug for the plugin"""
slug = getattr(self, 'PLUGIN_SLUG', None)
if not slug:
slug = self.plugin_name()
return slugify(slug)
return self.plugin_slug()
@property
def name(self):
return self.plugin_name()
@property
def human_name(self):

View File

@ -4,7 +4,7 @@ load templates for loaded plugins
from django.template.loaders.filesystem import Loader as FilesystemLoader
from pathlib import Path
from plugin import plugin_reg
from plugin import plugin_registry
class PluginTemplateLoader(FilesystemLoader):
@ -12,7 +12,7 @@ class PluginTemplateLoader(FilesystemLoader):
def get_dirs(self):
dirname = 'templates'
template_dirs = []
for plugin in plugin_reg.plugins.values():
for plugin in plugin_registry.plugins.values():
new_path = Path(plugin.path) / dirname
if Path(new_path).is_dir():
template_dirs.append(new_path)

View 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')},
},
),
]

View File

@ -1,6 +1,14 @@
"""utility class to enable simpler imports"""
from ..builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
"""
Utility class to enable simpler imports
"""
from ..builtin.integration.mixins import AppMixin, SettingsMixin, ScheduleMixin, UrlsMixin, NavigationMixin, APICallMixin
__all__ = [
'AppMixin', 'GlobalSettingsMixin', 'UrlsMixin', 'NavigationMixin', 'APICallMixin',
'AppMixin',
'NavigationMixin',
'ScheduleMixin',
'SettingsMixin',
'UrlsMixin',
'APICallMixin',
]

View File

@ -8,16 +8,17 @@ from __future__ import unicode_literals
from django.utils.translation import gettext_lazy as _
from django.db import models
from plugin import plugin_reg
import common.models
from plugin import InvenTreePlugin, plugin_registry
class PluginConfig(models.Model):
""" A PluginConfig object holds settings for plugins.
It is used to designate a Part as 'subscribed' for a given User.
"""
A PluginConfig object holds settings for plugins.
Attributes:
key: slug of the plugin - must be unique
key: slug of the plugin (this must be unique across all installed plugins!)
name: PluginName of the plugin - serves for a manual double check if the right plugin is used
active: Should the plugin be loaded?
"""
@ -54,17 +55,24 @@ class PluginConfig(models.Model):
# extra attributes from the registry
def mixins(self):
return self.plugin._mixinreg
try:
return self.plugin._mixinreg
except (AttributeError, ValueError):
return {}
# functions
def __init__(self, *args, **kwargs):
"""override to set original state of"""
"""
Override to set original state of the plugin-config instance
"""
super().__init__(*args, **kwargs)
self.__org_active = self.active
# append settings from registry
self.plugin = plugin_reg.plugins.get(self.key, None)
self.plugin = plugin_registry.plugins.get(self.key, None)
def get_plugin_meta(name):
if self.plugin:
@ -78,16 +86,112 @@ class PluginConfig(models.Model):
}
def save(self, force_insert=False, force_update=False, *args, **kwargs):
"""extend save method to reload plugins if the 'active' status changes"""
"""
Extend save method to reload plugins if the 'active' status changes
"""
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
ret = super().save(force_insert, force_update, *args, **kwargs)
if not reload:
if self.active is False and self.__org_active is True:
plugin_reg.reload_plugins()
plugin_registry.reload_plugins()
elif self.active is True and self.__org_active is False:
plugin_reg.reload_plugins()
plugin_registry.reload_plugins()
return ret
class PluginSetting(common.models.BaseInvenTreeSetting):
"""
This model represents settings for individual plugins
"""
class Meta:
unique_together = [
('plugin', 'key'),
]
def clean(self, **kwargs):
kwargs['plugin'] = self.plugin
super().clean(**kwargs)
"""
We override the following class methods,
so that we can pass the plugin instance
"""
@property
def name(self):
return self.__class__.get_setting_name(self.key, plugin=self.plugin)
@property
def default_value(self):
return self.__class__.get_setting_default(self.key, plugin=self.plugin)
@property
def description(self):
return self.__class__.get_setting_description(self.key, plugin=self.plugin)
@property
def units(self):
return self.__class__.get_setting_units(self.key, plugin=self.plugin)
def choices(self):
return self.__class__.get_setting_choices(self.key, plugin=self.plugin)
@classmethod
def get_setting_definition(cls, key, **kwargs):
"""
In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS',
which is a dict object that fully defines all the setting parameters.
Here, unlike the BaseInvenTreeSetting, we do not know the definitions of all settings
'ahead of time' (as they are defined externally in the plugins).
Settings can be provided by the caller, as kwargs['settings'].
If not provided, we'll look at the plugin registry to see what settings are available,
(if the plugin is specified!)
"""
if 'settings' not in kwargs:
plugin = kwargs.pop('plugin', None)
if plugin:
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
kwargs['settings'] = plugin_registry.mixins_settings.get(plugin.key, {})
return super().get_setting_definition(key, **kwargs)
@classmethod
def get_filters(cls, key, **kwargs):
"""
Override filters method to ensure settings are filtered by plugin id
"""
filters = super().get_filters(key, **kwargs)
plugin = kwargs.get('plugin', None)
if plugin:
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
filters['plugin'] = plugin
return filters
plugin = models.ForeignKey(
PluginConfig,
related_name='settings',
null=False,
verbose_name=_('Plugin'),
on_delete=models.CASCADE,
)

View File

@ -1,5 +1,10 @@
# -*- coding: utf-8 -*-
"""Base Class for InvenTree plugins"""
"""
Base Class for InvenTree plugins
"""
from django.db.utils import OperationalError, ProgrammingError
from django.utils.text import slugify
class InvenTreePlugin():
@ -7,12 +12,54 @@ class InvenTreePlugin():
Base class for a plugin
"""
def __init__(self):
pass
# Override the plugin name for each concrete plugin instance
PLUGIN_NAME = ''
PLUGIN_SLUG = None
PLUGIN_TITLE = None
def plugin_name(self):
"""get plugin name"""
"""
Return the name of this plugin plugin
"""
return self.PLUGIN_NAME
def __init__(self):
pass
def plugin_slug(self):
slug = getattr(self, 'PLUGIN_SLUG', None)
if slug is None:
slug = self.plugin_name()
return slugify(slug.lower())
def plugin_title(self):
if self.PLUGIN_TITLE:
return self.PLUGIN_TITLE
else:
return self.plugin_name()
def plugin_config(self, raise_error=False):
"""
Return the PluginConfig object associated with this plugin
"""
try:
import plugin.models
cfg, _ = plugin.models.PluginConfig.objects.get_or_create(
key=self.plugin_slug(),
name=self.plugin_name(),
)
except (OperationalError, ProgrammingError) as error:
cfg = None
if raise_error:
raise error
return cfg

View File

@ -1,7 +1,10 @@
"""
registry for plugins
holds the class and the object that contains all code to maintain plugin states
Registry for loading and managing multiple plugins at run-time
- Holds the class and the object that contains all code to maintain plugin states
- Manages setup and teardown of plugin class instances
"""
import importlib
import pathlib
import logging
@ -33,7 +36,11 @@ from .helpers import get_plugin_error, IntegrationPluginError
logger = logging.getLogger('inventree')
class Plugins:
class PluginsRegistry:
"""
The PluginsRegistry class
"""
def __init__(self) -> None:
# plugin registry
self.plugins = {}
@ -50,15 +57,19 @@ class Plugins:
# integration specific
self.installed_apps = [] # Holds all added plugin_paths
# mixins
self.mixins_globalsettings = {}
self.mixins_settings = {}
# region public plugin functions
def load_plugins(self):
"""load and activate all IntegrationPlugins"""
"""
Load and activate all IntegrationPlugins
"""
from plugin.helpers import log_plugin_error
logger.info('Start loading plugins')
# set maintanace mode
# Set maintanace mode
_maintenance = bool(get_maintenance_mode())
if not _maintenance:
set_maintenance_mode(True)
@ -68,7 +79,7 @@ class Plugins:
retry_counter = settings.PLUGIN_RETRY
while not registered_sucessfull:
try:
# we are using the db so for migrations etc we need to try this block
# We are using the db so for migrations etc we need to try this block
self._init_plugins(blocked_plugin)
self._activate_plugins()
registered_sucessfull = True
@ -81,13 +92,14 @@ class Plugins:
log_plugin_error({error.path: error.message}, 'load')
blocked_plugin = error.path # we will not try to load this app again
# init apps without any integration plugins
# Initialize apps without any integration plugins
self._clean_registry()
self._clean_installed_apps()
self._activate_plugins(force_reload=True)
# we do not want to end in an endless loop
# We do not want to end in an endless loop
retry_counter -= 1
if retry_counter <= 0:
if settings.PLUGIN_TESTING:
print('[PLUGIN] Max retries, breaking loading')
@ -98,15 +110,20 @@ class Plugins:
# now the loading will re-start up with init
# remove maintenance
# Remove maintenance mode
if not _maintenance:
set_maintenance_mode(False)
logger.info('Finished loading plugins')
def unload_plugins(self):
"""unload and deactivate all IntegrationPlugins"""
"""
Unload and deactivate all IntegrationPlugins
"""
logger.info('Start unloading plugins')
# set maintanace mode
# Set maintanace mode
_maintenance = bool(get_maintenance_mode())
if not _maintenance:
set_maintenance_mode(True)
@ -123,21 +140,27 @@ class Plugins:
logger.info('Finished unloading plugins')
def reload_plugins(self):
"""safely reload IntegrationPlugins"""
# do not reload whe currently loading
"""
Safely reload IntegrationPlugins
"""
# Do not reload whe currently loading
if self.is_loading:
return
logger.info('Start reloading plugins')
with maintenance_mode_on():
self.unload_plugins()
self.load_plugins()
logger.info('Finished reloading plugins')
# endregion
# region general plugin managment mechanisms
logger.info('Finished reloading plugins')
def collect_plugins(self):
"""collect integration plugins from all possible ways of loading"""
"""
Collect integration plugins from all possible ways of loading
"""
self.plugin_modules = [] # clear
# Collect plugins from paths
@ -146,35 +169,41 @@ class Plugins:
if modules:
[self.plugin_modules.append(item) for item in modules]
# check if not running in testing mode and apps should be loaded from hooks
# Check if not running in testing mode and apps should be loaded from hooks
if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP):
# Collect plugins from setup entry points
for entry in metadata.entry_points().get('inventree_plugins', []):
plugin = entry.load()
plugin.is_package = True
self.plugin_modules.append(plugin)
try:
plugin = entry.load()
plugin.is_package = True
self.plugin_modules.append(plugin)
except Exception as error:
get_plugin_error(error, do_log=True, log_name='discovery')
# Log collected plugins
logger.info(f'Collected {len(self.plugin_modules)} plugins!')
logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
def _init_plugins(self, disabled=None):
"""initialise all found plugins
"""
Initialise all found plugins
:param disabled: loading path of disabled app, defaults to None
:type disabled: str, optional
:raises error: IntegrationPluginError
"""
from plugin.models import PluginConfig
logger.info('Starting plugin initialisation')
# Initialize integration plugins
for plugin in self.plugin_modules:
# check if package
# Check if package
was_packaged = getattr(plugin, 'is_package', False)
# check if activated
# these checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
# Check if activated
# These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
plug_name = plugin.PLUGIN_NAME
plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
plug_key = slugify(plug_key) # keys are slugs!
@ -186,23 +215,23 @@ class Plugins:
raise error
plugin_db_setting = None
# always activate if testing
# Always activate if testing
if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):
# check if the plugin was blocked -> threw an error
# Check if the plugin was blocked -> threw an error
if disabled:
# option1: package, option2: file-based
if (plugin.__name__ == disabled) or (plugin.__module__ == disabled):
# errors are bad so disable the plugin in the database
# Errors are bad so disable the plugin in the database
if not settings.PLUGIN_TESTING:
plugin_db_setting.active = False
# TODO save the error to the plugin
plugin_db_setting.save(no_reload=True)
# add to inactive plugins so it shows up in the ui
# Add to inactive plugins so it shows up in the ui
self.plugins_inactive[plug_key] = plugin_db_setting
continue # continue -> the plugin is not loaded
# init package
# Initialize package
# now we can be sure that an admin has activated the plugin
# TODO check more stuff -> as of Nov 2021 there are not many checks in place
# but we could enhance those to check signatures, run the plugin against a whitelist etc.
@ -225,7 +254,8 @@ class Plugins:
self.plugins_inactive[plug_key] = plugin_db_setting
def _activate_plugins(self, force_reload=False):
"""run integration functions for all plugins
"""
Run integration functions for all plugins
:param force_reload: force reload base apps, defaults to False
:type force_reload: bool, optional
@ -234,49 +264,91 @@ class Plugins:
plugins = self.plugins.items()
logger.info(f'Found {len(plugins)} active plugins')
self.activate_integration_globalsettings(plugins)
self.activate_integration_settings(plugins)
self.activate_integration_schedule(plugins)
self.activate_integration_app(plugins, force_reload=force_reload)
def _deactivate_plugins(self):
"""run integration deactivation functions for all plugins"""
"""
Run integration deactivation functions for all plugins
"""
self.deactivate_integration_app()
self.deactivate_integration_globalsettings()
# endregion
self.deactivate_integration_schedule()
self.deactivate_integration_settings()
# region specific integrations
# region integration_globalsettings
def activate_integration_globalsettings(self, plugins):
from common.models import InvenTreeSetting
def activate_integration_settings(self, plugins):
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'):
logger.info('Registering IntegrationPlugin global settings')
for slug, plugin in plugins:
if plugin.mixin_enabled('globalsettings'):
plugin_setting = plugin.globalsettingspatterns
self.mixins_globalsettings[slug] = plugin_setting
logger.info('Activating plugin settings')
# Add to settings dir
InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
self.mixins_settings = {}
def deactivate_integration_globalsettings(self):
from common.models import InvenTreeSetting
for slug, plugin in plugins:
if plugin.mixin_enabled('settings'):
plugin_setting = plugin.settings
self.mixins_settings[slug] = plugin_setting
def deactivate_integration_settings(self):
# collect all settings
plugin_settings = {}
for _, plugin_setting in self.mixins_globalsettings.items():
for _, plugin_setting in self.mixins_settings.items():
plugin_settings.update(plugin_setting)
# remove settings
for setting in plugin_settings:
InvenTreeSetting.GLOBAL_SETTINGS.pop(setting)
# clear cache
self.mixins_globalsettings = {}
# endregion
self.mixins_settings = {}
def activate_integration_schedule(self, plugins):
logger.info('Activating plugin tasks')
from common.models import InvenTreeSetting
# List of tasks we have activated
task_keys = []
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SCHEDULE'):
for slug, plugin in plugins:
if plugin.mixin_enabled('schedule'):
config = plugin.plugin_config()
# Only active tasks for plugins which are enabled
if config and config.active:
plugin.register_tasks()
task_keys += plugin.get_task_names()
if len(task_keys) > 0:
logger.info(f"Activated {len(task_keys)} scheduled tasks")
# Remove any scheduled tasks which do not match
# This stops 'old' plugin tasks from accumulating
try:
from django_q.models import Schedule
scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.")
deleted_count = 0
for task in scheduled_plugin_tasks:
if task.name not in task_keys:
task.delete()
deleted_count += 1
if deleted_count > 0:
logger.info(f"Removed {deleted_count} old scheduled tasks")
except (ProgrammingError, OperationalError):
# Database might not yet be ready
logger.warning("activate_integration_schedule failed, database not ready")
def deactivate_integration_schedule(self):
pass
# region integration_app
def activate_integration_app(self, plugins, force_reload=False):
"""activate AppMixin plugins - add custom apps and reload
"""
Activate AppMixin plugins - add custom apps and reload
:param plugins: list of IntegrationPlugins that should be installed
:type plugins: dict
@ -360,7 +432,10 @@ class Plugins:
return plugin_path
def deactivate_integration_app(self):
"""deactivate integration app - some magic required"""
"""
Deactivate integration app - some magic required
"""
# unregister models from admin
for plugin_path in self.installed_apps:
models = [] # the modelrefs need to be collected as poping an item in a iter is not welcomed
@ -448,8 +523,6 @@ class Plugins:
return True, []
except Exception as error:
get_plugin_error(error, do_raise=True)
# endregion
# endregion
plugins = Plugins()
plugin_registry = PluginsRegistry()

View File

@ -1,15 +1,18 @@
"""sample implementations for IntegrationPlugin"""
"""
Sample implementations for IntegrationPlugin
"""
from plugin import IntegrationPluginBase
from plugin.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin
from django.http import HttpResponse
from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include
class SampleIntegrationPlugin(AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase):
class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase):
"""
An full integration plugin
A full integration plugin example
"""
PLUGIN_NAME = "SampleIntegrationPlugin"
@ -41,6 +44,27 @@ class SampleIntegrationPlugin(AppMixin, GlobalSettingsMixin, UrlsMixin, Navigati
'default': True,
'validator': bool,
},
'API_KEY': {
'name': _('API Key'),
'description': _('Key required for accessing external API'),
},
'NUMERICAL_SETTING': {
'name': _('Numerical'),
'description': _('A numerical setting'),
'validator': int,
'default': 123,
},
'CHOICE_SETTING': {
'name': _("Choice Setting"),
'description': _('A setting with multiple choices'),
'choices': [
('A', 'Anaconda'),
('B', 'Bat'),
('C', 'Cat'),
('D', 'Dog'),
],
'default': 'A',
},
}
NAVIGATION = [

View 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',
},
}

View File

@ -1,5 +1,5 @@
"""
JSON serializers for Stock app
JSON serializers for plugin app
"""
# -*- coding: utf-8 -*-
@ -11,14 +11,18 @@ import subprocess
from django.core.exceptions import ValidationError
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from rest_framework import serializers
from plugin.models import PluginConfig
from plugin.models import PluginConfig, PluginSetting
from InvenTree.config import get_plugin_file
from common.serializers import SettingsSerializer
class PluginConfigSerializer(serializers.ModelSerializer):
""" Serializer for a PluginConfig:
"""
Serializer for a PluginConfig:
"""
meta = serializers.DictField(read_only=True)
@ -71,7 +75,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
if not data.get('confirm'):
raise ValidationError({'confirm': _('Installation not confirmed')})
if (not data.get('url')) and (not data.get('packagename')):
msg = _('Either packagenmae of url must be provided')
msg = _('Either packagename of URL must be provided')
raise ValidationError({'url': msg, 'packagename': msg})
return data
@ -83,37 +87,64 @@ class PluginConfigInstallSerializer(serializers.Serializer):
url = data.get('url', '')
# build up the command
command = 'python -m pip install'.split()
install_name = []
if url:
# use custom registration / VCS
if True in [identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn', ]]:
# using a VCS provider
if packagename:
command.append(f'{packagename}@{url}')
install_name.append(f'{packagename}@{url}')
else:
command.append(url)
install_name.append(url)
else:
# using a custom package repositories
command.append('-i')
command.append(url)
command.append(packagename)
install_name.append('-i')
install_name.append(url)
install_name.append(packagename)
elif packagename:
# use pypi
command.append(packagename)
install_name.append(packagename)
command = 'python -m pip install'.split()
command.extend(install_name)
ret = {'command': ' '.join(command)}
success = False
# execute pypi
try:
result = subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR))
ret['result'] = str(result, 'utf-8')
ret['success'] = True
success = True
except subprocess.CalledProcessError as error:
ret['result'] = str(error.output, 'utf-8')
ret['error'] = True
# register plugins
# TODO
# save plugin to plugin_file if installed successfull
if success:
with open(get_plugin_file(), "a") as plugin_file:
plugin_file.write(f'{" ".join(install_name)} # Installed {timezone.now()} by {str(self.context["request"].user)}\n')
return ret
class PluginSettingSerializer(SettingsSerializer):
"""
Serializer for the PluginSetting model
"""
plugin = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta:
model = PluginSetting
fields = [
'pk',
'key',
'value',
'name',
'description',
'type',
'choices',
'plugin',
]

View File

@ -7,7 +7,7 @@ from django import template
from django.urls import reverse
from common.models import InvenTreeSetting
from plugin import plugin_reg
from plugin import plugin_registry
register = template.Library()
@ -16,19 +16,19 @@ register = template.Library()
@register.simple_tag()
def plugin_list(*args, **kwargs):
""" Return a list of all installed integration plugins """
return plugin_reg.plugins
return plugin_registry.plugins
@register.simple_tag()
def inactive_plugin_list(*args, **kwargs):
""" Return a list of all inactive integration plugins """
return plugin_reg.plugins_inactive
return plugin_registry.plugins_inactive
@register.simple_tag()
def plugin_globalsettings(plugin, *args, **kwargs):
""" Return a list of all global settings for a plugin """
return plugin_reg.mixins_globalsettings.get(plugin)
def plugin_settings(plugin, *args, **kwargs):
""" Return a list of all custom settings for a plugin """
return plugin_registry.mixins_settings.get(plugin)
@register.simple_tag()
@ -57,4 +57,4 @@ def safe_url(view_name, *args, **kwargs):
@register.simple_tag()
def plugin_errors(*args, **kwargs):
"""Return all plugin errors"""
return plugin_reg.errors
return plugin_registry.errors

View File

@ -8,7 +8,7 @@ from InvenTree.api_tester import InvenTreeAPITestCase
class PluginDetailAPITest(InvenTreeAPITestCase):
"""
Tests the plugin AP I endpoints
Tests the plugin API endpoints
"""
roles = [
@ -19,7 +19,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
]
def setUp(self):
self.MSG_NO_PKG = 'Either packagenmae of url must be provided'
self.MSG_NO_PKG = 'Either packagename of URL must be provided'
self.PKG_NAME = 'minimal'
super().setUp()
@ -64,14 +64,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
Test the PluginConfig action commands
"""
from plugin.models import PluginConfig
from plugin import plugin_reg
from plugin import plugin_registry
url = reverse('admin:plugin_pluginconfig_changelist')
fixtures = PluginConfig.objects.all()
# check if plugins were registered -> in some test setups the startup has no db access
if not fixtures:
plugin_reg.reload_plugins()
plugin_registry.reload_plugins()
fixtures = PluginConfig.objects.all()
print([str(a) for a in fixtures])

View File

@ -8,7 +8,7 @@ from django.contrib.auth import get_user_model
from datetime import datetime
from plugin import IntegrationPluginBase
from plugin.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin
from plugin.urls import PLUGIN_BASE
@ -20,19 +20,19 @@ class BaseMixinDefinition:
self.assertEqual(self.mixin.registered_mixins[0]['human_name'], self.MIXIN_HUMAN_NAME)
class GlobalSettingsMixinTest(BaseMixinDefinition, TestCase):
MIXIN_HUMAN_NAME = 'Global settings'
MIXIN_NAME = 'globalsettings'
MIXIN_ENABLE_CHECK = 'has_globalsettings'
class SettingsMixinTest(BaseMixinDefinition, TestCase):
MIXIN_HUMAN_NAME = 'Settings'
MIXIN_NAME = 'settings'
MIXIN_ENABLE_CHECK = 'has_settings'
TEST_SETTINGS = {'SETTING1': {'default': '123', }}
def setUp(self):
class SettingsCls(GlobalSettingsMixin, IntegrationPluginBase):
GLOBALSETTINGS = self.TEST_SETTINGS
class SettingsCls(SettingsMixin, IntegrationPluginBase):
SETTINGS = self.TEST_SETTINGS
self.mixin = SettingsCls()
class NoSettingsCls(GlobalSettingsMixin, IntegrationPluginBase):
class NoSettingsCls(SettingsMixin, IntegrationPluginBase):
pass
self.mixin_nothing = NoSettingsCls()
@ -42,25 +42,19 @@ class GlobalSettingsMixinTest(BaseMixinDefinition, TestCase):
def test_function(self):
# settings variable
self.assertEqual(self.mixin.globalsettings, self.TEST_SETTINGS)
# settings pattern
target_pattern = {f'PLUGIN_{self.mixin.slug.upper()}_{key}': value for key, value in self.mixin.globalsettings.items()}
self.assertEqual(self.mixin.globalsettingspatterns, target_pattern)
# no settings
self.assertIsNone(self.mixin_nothing.globalsettings)
self.assertIsNone(self.mixin_nothing.globalsettingspatterns)
self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
# calling settings
# not existing
self.assertEqual(self.mixin.get_globalsetting('ABCD'), '')
self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '')
self.assertEqual(self.mixin.get_setting('ABCD'), '')
self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '')
# right setting
self.mixin.set_globalsetting('SETTING1', '12345', self.test_user)
self.assertEqual(self.mixin.get_globalsetting('SETTING1'), '12345')
self.mixin.set_setting('SETTING1', '12345', self.test_user)
self.assertEqual(self.mixin.get_setting('SETTING1'), '12345')
# no setting
self.assertEqual(self.mixin_nothing.get_globalsetting(''), '')
self.assertEqual(self.mixin_nothing.get_setting(''), '')
class UrlsMixinTest(BaseMixinDefinition, TestCase):

View File

@ -1,4 +1,6 @@
""" Unit tests for plugins """
"""
Unit tests for plugins
"""
from django.test import TestCase
@ -6,9 +8,8 @@ import plugin.plugin
import plugin.integration
from plugin.samples.integration.sample import SampleIntegrationPlugin
from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
# from plugin.plugins import load_action_plugins, load_barcode_plugins
import plugin.templatetags.plugin_extras as plugin_tags
from plugin import plugin_reg
from plugin import plugin_registry
class InvenTreePluginTests(TestCase):
@ -57,17 +58,17 @@ class PluginTagTests(TestCase):
def test_tag_plugin_list(self):
"""test that all plugins are listed"""
self.assertEqual(plugin_tags.plugin_list(), plugin_reg.plugins)
self.assertEqual(plugin_tags.plugin_list(), plugin_registry.plugins)
def test_tag_incative_plugin_list(self):
"""test that all inactive plugins are listed"""
self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_reg.plugins_inactive)
self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_registry.plugins_inactive)
def test_tag_plugin_globalsettings(self):
def test_tag_plugin_settings(self):
"""check all plugins are listed"""
self.assertEqual(
plugin_tags.plugin_globalsettings(self.sample),
plugin_reg.mixins_globalsettings.get(self.sample)
plugin_tags.plugin_settings(self.sample),
plugin_registry.mixins_settings.get(self.sample)
)
def test_tag_mixin_enabled(self):
@ -89,4 +90,4 @@ class PluginTagTests(TestCase):
def test_tag_plugin_errors(self):
"""test that all errors are listed"""
self.assertEqual(plugin_tags.plugin_errors(), plugin_reg.errors)
self.assertEqual(plugin_tags.plugin_errors(), plugin_registry.errors)

View File

@ -1,18 +1,24 @@
"""
URL lookup for plugin app
"""
from django.conf.urls import url, include
from plugin import plugin_reg
from plugin import plugin_registry
PLUGIN_BASE = 'plugin' # Constant for links
def get_plugin_urls():
"""returns a urlpattern that can be integrated into the global urls"""
"""
Returns a urlpattern that can be integrated into the global urls
"""
urls = []
for plugin in plugin_reg.plugins.values():
for plugin in plugin_registry.plugins.values():
if plugin.mixin_enabled('urls'):
urls.append(plugin.urlpatterns)
return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))

View File

@ -480,18 +480,6 @@ class StockList(generics.ListCreateAPIView):
notes = data.get('notes', '')
serials = None
if serial_numbers:
# If serial numbers are specified, check that they match!
try:
serials = extract_serial_numbers(serial_numbers, data['quantity'])
except DjangoValidationError as e:
raise ValidationError({
'quantity': e.messages,
'serial_numbers': e.messages,
})
with transaction.atomic():
# Create an initial stock item
@ -507,6 +495,19 @@ class StockList(generics.ListCreateAPIView):
if item.part.default_expiry > 0:
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
# fetch serial numbers
serials = None
if serial_numbers:
# If serial numbers are specified, check that they match!
try:
serials = extract_serial_numbers(serial_numbers, quantity, item.part.getLatestSerialNumberInt())
except DjangoValidationError as e:
raise ValidationError({
'quantity': e.messages,
'serial_numbers': e.messages,
})
# Finally, save the item (with user information)
item.save(user=user)

View File

@ -350,7 +350,7 @@ class SerializeStockItemSerializer(serializers.Serializer):
serial_numbers = data['serial_numbers']
try:
serials = InvenTree.helpers.extract_serial_numbers(serial_numbers, quantity)
serials = InvenTree.helpers.extract_serial_numbers(serial_numbers, quantity, item.part.getLatestSerialNumberInt())
except DjangoValidationError as e:
raise ValidationError({
'serial_numbers': e.messages,
@ -379,6 +379,7 @@ class SerializeStockItemSerializer(serializers.Serializer):
serials = InvenTree.helpers.extract_serial_numbers(
data['serial_numbers'],
data['quantity'],
item.part.getLatestSerialNumberInt()
)
item.serializeStock(

View File

@ -3,6 +3,7 @@
{% load inventree_extras %}
{% load status_codes %}
{% load i18n %}
{% load l10n %}
{% block page_title %}
{% inventree_title %} | {% trans "Stock Item" %} - {{ item }}
@ -429,7 +430,7 @@ $("#stock-serialize").click(function() {
part: {{ item.part.pk }},
reload: true,
data: {
quantity: {{ item.quantity }},
quantity: {{ item.quantity|unlocalize }},
{% if item.location %}
destination: {{ item.location.pk }},
{% elif item.part.default_location %}

View File

@ -1241,7 +1241,7 @@ class StockItemCreate(AjaxCreateView):
if len(sn) > 0:
try:
serials = extract_serial_numbers(sn, quantity)
serials = extract_serial_numbers(sn, quantity, part.getLatestSerialNumberInt())
except ValidationError as e:
serials = None
form.add_error('serial_numbers', e.messages)
@ -1283,7 +1283,7 @@ class StockItemCreate(AjaxCreateView):
# Create a single stock item for each provided serial number
if len(sn) > 0:
serials = extract_serial_numbers(sn, quantity)
serials = extract_serial_numbers(sn, quantity, part.getLatestSerialNumberInt())
for serial in serials:
item = StockItem(

View File

@ -13,19 +13,19 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-user-shield" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-user-lock" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-at" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" icon='fa-key' %}
<tr>
<th><h5>{% trans 'Signup' %}</h5></th>
<td colspan='4'></td>
</tr>
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-user-plus" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-at" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-user-lock" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-key" %}
{% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" icon="fa-users" %}
</tbody>
</table>

View File

@ -1,13 +1,16 @@
{% load i18n %}
{% load plugin_extras %}
<h4>{% trans "Settings" %}</h4>
{% plugin_globalsettings plugin_key as plugin_settings %}
<div class='panel-heading'>
<h4>{% trans "Settings" %}</h4>
</div>
{% plugin_settings plugin_key as plugin_settings %}
<table class='table table-striped table-condensed'>
<tbody>
{% for setting in plugin_settings %}
{% include "InvenTree/settings/setting.html" with key=setting%}
{% include "InvenTree/settings/setting.html" with key=setting plugin=plugin %}
{% endfor %}
</tbody>
</table>

View File

@ -1,7 +1,9 @@
{% load i18n %}
{% load inventree_extras %}
<h4>{% trans "URLs" %}</h4>
<div class='panel-heading'>
<h4>{% trans "URLs" %}</h4>
</div>
{% define plugin.base_url as base %}
<p>{% blocktrans %}The Base-URL for this plugin is <a href="/{{ base }}" target="_blank"><strong>{{ base }}</strong></a>.{% endblocktrans %}</p>
@ -18,7 +20,7 @@
<tr>
<td>{{key}}</td>
<td>{{entry.1}}</td>
<td><a class="btn btn-primary btn-small" href="/{{ base }}{{entry.1}}" target="_blank">{% trans 'open in new tab' %}</a></td>
<td><a class="btn btn-primary btn-small" href="/{{ base }}{{entry.1}}" target="_blank">{% trans 'Open in new tab' %}</a></td>
</tr>
{% endif %}{% endfor %}
</tbody>

View File

@ -19,17 +19,17 @@
<div class='table-responsive'>
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_GLOBALSETTING"%}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_SCHEDULE" icon="fa-calendar-alt" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}
</tbody>
</table>
</div>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Plugin list" %}</h4>
<h4>{% trans "Plugins" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% url 'admin:plugin_pluginconfig_changelist' as url %}
@ -70,7 +70,7 @@
{% if mixin_list %}
{% for mixin in mixin_list %}
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
<span class='badge bg-dark badge-right'>{% blocktrans with name=mixin.human_name %}has {{name}}{% endblocktrans %}</span>
<span class='badge bg-dark badge-right rounded-pill'>{{ mixin.human_name }}</span>
</a>
{% endfor %}
{% endif %}

View File

@ -67,7 +67,10 @@
</div>
{% if plugin.is_package == False %}
<p>{% trans 'The code information is pulled from the latest git commit for this plugin. It might not reflect official version numbers or information but the actual code running.' %}</p>
<div class='alert alert-block alert-info'>
{% trans 'The code information is pulled from the latest git commit for this plugin. It might not reflect official version numbers or information but the actual code running.' %}
</div>
{% endif %}
</div>
<div class="col">
@ -124,8 +127,8 @@
</div>
</div>
{% mixin_enabled plugin 'globalsettings' as globalsettings %}
{% if globalsettings %}
{% mixin_enabled plugin 'settings' as settings %}
{% if settings %}
{% include 'InvenTree/settings/mixins/settings.html' %}
{% endif %}

View File

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

View File

@ -1,10 +1,12 @@
{% load inventree_extras %}
{% load i18n %}
{% if user_setting %}
{% setting_object key user=request.user as setting %}
{% if plugin %}
{% setting_object key plugin=plugin as setting %}
{% elif user_setting %}
{% setting_object key user=request.user as setting %}
{% else %}
{% setting_object key as setting %}
{% setting_object key as setting %}
{% endif %}
<tr>
@ -13,7 +15,7 @@
<span class='fas {{ icon }}'></span>
{% endif %}
</td>
<td><strong>{% trans setting.name %}</strong></td>
<td><strong>{{ setting.name }}</strong></td>
<td>
{% if setting.is_bool %}
<div class='form-check form-switch'>
@ -32,11 +34,11 @@
</div>
{% endif %}
<td>
{% trans setting.description %}
{{ setting.description }}
</td>
<td>
<div class='btn-group float-right'>
<button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if user_setting %}user='{{request.user.id}}'{% endif %}>
<button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if plugin %}plugin='{{ plugin.pk }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}>
<span class='fas fa-edit icon-green'></span>
</button>
</div>

View File

@ -62,16 +62,27 @@
$('table').find('.btn-edit-setting').click(function() {
var setting = $(this).attr('setting');
var pk = $(this).attr('pk');
var plugin = $(this).attr('plugin');
var is_global = true;
if ($(this).attr('user')){
is_global = false;
}
var title = '';
if (plugin != null) {
title = '{% trans "Edit Plugin Setting" %}';
} else if (is_global) {
title = '{% trans "Edit Global Setting" %}';
} else {
title = '{% trans "Edit User Setting" %}';
}
editSetting(pk, {
plugin: plugin,
global: is_global,
title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}',
title: title,
});
});

View File

@ -49,7 +49,7 @@
{% include "sidebar_header.html" with text="Plugin Settings" %}
{% include "sidebar_item.html" with label='plugin' text="Plugin" icon="fa-plug" %}
{% include "sidebar_item.html" with label='plugin' text="Plugins" icon="fa-plug" %}
{% plugin_list as pl_list %}
{% for plugin_key, plugin in pl_list.items %}

View File

@ -28,9 +28,13 @@ function editSetting(pk, options={}) {
// Is this a global setting or a user setting?
var global = options.global || false;
var plugin = options.plugin;
var url = '';
if (global) {
if (plugin) {
url = `/api/plugin/settings/${pk}/`;
} else if (global) {
url = `/api/settings/global/${pk}/`;
} else {
url = `/api/settings/user/${pk}/`;

View File

@ -20,6 +20,7 @@
/* exported
allocateStockToBuild,
completeBuildOrder,
editBuildOrder,
loadAllocationTable,
loadBuildOrderAllocationTable,
@ -120,6 +121,57 @@ function newBuildOrder(options={}) {
}
/* Construct a form to "complete" (finish) a build order */
function completeBuildOrder(build_id, options={}) {
var url = `/api/build/${build_id}/finish/`;
var fields = {
accept_unallocated: {},
accept_incomplete: {},
};
var html = '';
if (options.can_complete) {
} else {
html += `
<div class='alert alert-block alert-danger'>
<strong>{% trans "Build Order is incomplete" %}</strong>
</div>
`;
if (!options.allocated) {
html += `<div class='alert alert-block alert-warning'>{% trans "Required stock has not been fully allocated" %}</div>`;
}
if (!options.completed) {
html += `<div class='alert alert-block alert-warning'>{% trans "Required build quantity has not been completed" %}</div>`;
}
}
// Hide particular fields if they are not required
if (options.allocated) {
delete fields.accept_unallocated;
}
if (options.completed) {
delete fields.accept_incomplete;
}
constructForm(url, {
fields: fields,
reload: true,
confirm: true,
method: 'POST',
title: '{% trans "Complete Build Order" %}',
preFormContent: html,
});
}
/*
* Construct a set of output buttons for a particular build output
*/

View File

@ -371,7 +371,12 @@ function customGroupSorter(sortName, sortOrder, sortData) {
return `${pageNumber} {% trans "rows per page" %}`;
},
formatShowingRows: function(pageFrom, pageTo, totalRows) {
return `{% trans "Showing" %} ${pageFrom} {% trans "to" %} ${pageTo} {% trans "of" %} ${totalRows} {% trans "rows" %}`;
if (totalRows === undefined || totalRows === NaN) {
return '{% trans "Showing all rows" %}';
} else {
return `{% trans "Showing" %} ${pageFrom} {% trans "to" %} ${pageTo} {% trans "of" %} ${totalRows} {% trans "rows" %}`;
}
},
formatSearch: function() {
return '{% trans "Search" %}';

View File

@ -76,7 +76,8 @@ class RuleSet(models.Model):
'otp_totp_totpdevice',
'otp_static_statictoken',
'otp_static_staticdevice',
'plugin_pluginconfig'
'plugin_pluginconfig',
'plugin_pluginsetting',
],
'part_category': [
'part_partcategory',

View File

@ -30,9 +30,11 @@ ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree"
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
ENV INVENTREE_PLUGIN_FILE="${INVENTREE_DATA_DIR}/plugins.txt"
# Worker configuration (can be altered by user)
ENV INVENTREE_GUNICORN_WORKERS="4"
@ -59,6 +61,7 @@ RUN apk -U upgrade
# Install required system packages
RUN apk add --no-cache git make bash \
gcc libgcc g++ libstdc++ \
gnupg \
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
libffi libffi-dev \
zlib zlib-dev \
@ -128,8 +131,12 @@ ENV INVENTREE_PY_ENV="${INVENTREE_DEV_DIR}/env"
# Override default path settings
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static"
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DEV_DIR}/media"
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DEV_DIR}/plugins"
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DEV_DIR}/config.yaml"
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DEV_DIR}/secret_key.txt"
ENV INVENTREE_PLUGIN_FILE="${INVENTREE_DEV_DIR}/plugins.txt"
WORKDIR ${INVENTREE_HOME}

View File

@ -71,17 +71,32 @@ def manage(c, cmd, pty=False):
cmd=cmd
), pty=pty)
@task
def plugins(c):
"""
Installs all plugins as specified in 'plugins.txt'
"""
from InvenTree.InvenTree.config import get_plugin_file
plugin_file = get_plugin_file()
print(f"Installing plugin packages from '{plugin_file}'")
# Install the plugins
c.run(f"pip3 install -U -r '{plugin_file}'")
@task(post=[plugins])
def install(c):
"""
Installs required python packages
"""
print("Installing required python packages from 'requirements.txt'")
# Install required Python packages with PIP
c.run('pip3 install -U -r requirements.txt')
@task
def shell(c):
"""