Merge branch 'fix-boolean' of https://github.com/matmair/InvenTree into fix-boolean

This commit is contained in:
Matthias 2022-05-15 17:52:34 +02:00
commit 136d642703
No known key found for this signature in database
GPG Key ID: AB6D0E6C4CB65093
79 changed files with 1682 additions and 1215 deletions

View File

@ -124,6 +124,16 @@ jobs:
env:
wrapper_name: inventree-python
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3
INVENTREE_MEDIA_ROOT: ../test_inventree_media
INVENTREE_STATIC_ROOT: ../test_inventree_static
INVENTREE_ADMIN_USER: testuser
INVENTREE_ADMIN_PASSWORD: testpassword
INVENTREE_ADMIN_EMAIL: test@test.com
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
INVENTREE_PYTHON_TEST_USERNAME: testuser
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
steps:
- name: Checkout Code
@ -140,13 +150,14 @@ jobs:
git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }}
- name: Start Server
run: |
invoke import-records -f ./${{ env.wrapper_name }}/test/test_data.json
invoke server -a 127.0.0.1:8000 &
sleep ${{ env.server_start_sleep }}
invoke delete-data -f
invoke import-fixtures
invoke server -a 127.0.0.1:12345 &
- name: Run Tests
run: |
cd ${{ env.wrapper_name }}
invoke test
invoke check-server
coverage run -m unittest discover -s test/
coverage:
name: Sqlite / coverage

View File

@ -13,15 +13,11 @@ from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from .views import AjaxView
from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
from .status import is_worker_running
from plugin import registry
class InfoView(AjaxView):
""" Simple JSON endpoint for InvenTree information.
@ -61,6 +57,44 @@ class NotFoundView(AjaxView):
return JsonResponse(data, status=404)
class APIDownloadMixin:
"""
Mixin for enabling a LIST endpoint to be downloaded a file.
To download the data, add the ?export=<fmt> to the query string.
The implementing class must provided a download_queryset method,
e.g.
def download_queryset(self, queryset, export_format):
dataset = StockItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
date=datetime.now().strftime("%d-%b-%Y"),
fmt=export_format
)
return DownloadFile(filedata, filename)
"""
def get(self, request, *args, **kwargs):
export_format = request.query_params.get('export', None)
if export_format and export_format in ['csv', 'tsv', 'xls', 'xlsx']:
queryset = self.filter_queryset(self.get_queryset())
return self.download_queryset(queryset, export_format)
else:
# Default to the parent class implementation
return super().get(request, *args, **kwargs)
def download_queryset(self, queryset, export_format):
raise NotImplementedError("download_queryset method not implemented!")
class AttachmentMixin:
"""
Mixin for creating attachment objects,
@ -81,40 +115,3 @@ class AttachmentMixin:
attachment = serializer.save()
attachment.user = self.request.user
attachment.save()
class ActionPluginView(APIView):
"""
Endpoint for running custom action plugins.
"""
permission_classes = [
permissions.IsAuthenticated,
]
def post(self, request, *args, **kwargs):
action = request.data.get('action', None)
data = request.data.get('data', None)
if action is None:
return Response({
'error': _("No action specified")
})
action_plugins = registry.with_mixin('action')
for plugin in action_plugins:
if plugin.action_name() == action:
# TODO @matmair use easier syntax once InvenTree 0.7.0 is released
plugin.init(request.user, data=data)
plugin.perform_action()
return Response(plugin.get_response())
# If we got to here, no matching action was found
return Response({
'error': _("No matching action found"),
"action": action,
})

View File

@ -4,11 +4,16 @@ InvenTree API version information
# InvenTree API version
INVENTREE_API_VERSION = 47
INVENTREE_API_VERSION = 48
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977
- Adds "export to file" functionality for PurchaseOrder API endpoint
- Adds "export to file" functionality for SalesOrder API endpoint
- Adds "export to file" functionality for BuildOrder API endpoint
v47 -> 2022-05-10 : https://github.com/inventree/InvenTree/pull/2964
- Fixes barcode API error response when scanning a StockItem which does not exist
- Fixes barcode API error response when scanning a StockLocation which does not exist

View File

@ -197,8 +197,6 @@ class InvenTreeConfig(AppConfig):
logger.info(f'User {str(new_user)} was created!')
except IntegrityError as _e:
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}')
if settings.TESTING_ENV:
raise _e
# do not try again
settings.USER_ADDED = True

View File

@ -1,9 +1,11 @@
from django.shortcuts import HttpResponseRedirect
from django.urls import reverse_lazy, Resolver404
from django.shortcuts import redirect
from django.urls import include, re_path
# -*- coding: utf-8 -*-
from django.conf import settings
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
from django.http import HttpResponse
from django.shortcuts import redirect
from django.urls import reverse_lazy, Resolver404
from django.urls import include, re_path
import logging
@ -68,10 +70,6 @@ class AuthRequiredMiddleware(object):
# No authorization was found for the request
if not authorized:
# A logout request will redirect the user to the login screen
if request.path_info == reverse_lazy('account_logout'):
return HttpResponseRedirect(reverse_lazy('account_login'))
path = request.path_info
# List of URL endpoints we *do not* want to redirect to
@ -82,11 +80,23 @@ class AuthRequiredMiddleware(object):
reverse_lazy('admin:logout'),
]
if path not in urls and not path.startswith('/api/'):
# Do not redirect requests to any of these paths
paths_ignore = [
'/api/',
'/js/',
'/media/',
'/static/',
]
if path not in urls and not any([path.startswith(p) for p in paths_ignore]):
# Save the 'next' parameter to pass through to the login view
return redirect('{}?next={}'.format(reverse_lazy('account_login'), request.path))
else:
# Return a 401 (Unauthorized) response code for this request
return HttpResponse('Unauthorized', status=401)
response = self.get_response(request)
return response

View File

@ -96,6 +96,12 @@ class HTMLAPITests(TestCase):
response = self.client.get(url, HTTP_ACCEPT='text/html')
self.assertEqual(response.status_code, 200)
def test_not_found(self):
"""Test that the NotFoundView is working"""
response = self.client.get('/api/anc')
self.assertEqual(response.status_code, 404)
class APITests(InvenTreeAPITestCase):
""" Tests for the InvenTree API """

View File

@ -0,0 +1,66 @@
"""Tests for middleware functions"""
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.urls import reverse
class MiddlewareTests(TestCase):
"""Test for middleware functions"""
def check_path(self, url, code=200, **kwargs):
response = self.client.get(url, HTTP_ACCEPT='application/json', **kwargs)
self.assertEqual(response.status_code, code)
return response
def setUp(self):
super().setUp()
# Create a user
user = get_user_model()
self.user = user.objects.create_user(username='username', email='user@email.com', password='password')
self.client.login(username='username', password='password')
def test_AuthRequiredMiddleware(self):
"""Test the auth middleware"""
# test that /api/ routes go through
self.check_path(reverse('api-inventree-info'))
# logout
self.client.logout()
# check that account things go through
self.check_path(reverse('account_login'))
# logout goes diretly to login
self.check_path(reverse('account_logout'))
# check that frontend code is redirected to login
response = self.check_path(reverse('stats'), 302)
self.assertEqual(response.url, '/accounts/login/?next=/stats/')
# check that a 401 is raised
self.check_path(reverse('settings.js'), 401)
def test_token_auth(self):
"""Test auth with token auth"""
# get token
response = self.client.get(reverse('api-token'), format='json', data={})
token = response.data['token']
# logout
self.client.logout()
# this should raise a 401
self.check_path(reverse('settings.js'), 401)
# request with token
self.check_path(reverse('settings.js'), HTTP_Authorization=f'Token {token}')
# Request with broken token
self.check_path(reverse('settings.js'), 401, HTTP_Authorization='Token abcd123')
# should still fail without token
self.check_path(reverse('settings.js'), 401)

View File

@ -451,6 +451,11 @@ class TestSettings(TestCase):
self.user_mdl = get_user_model()
self.env = EnvironmentVarGuard()
# Create a user for auth
user = get_user_model()
self.user = user.objects.create_superuser('testuser1', 'test1@testing.com', 'password1')
self.client.login(username='testuser1', password='password1')
def run_reload(self):
from plugin import registry
@ -467,23 +472,49 @@ class TestSettings(TestCase):
# nothing set
self.run_reload()
self.assertEqual(user_count(), 0)
self.assertEqual(user_count(), 1)
# not enough set
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
self.run_reload()
self.assertEqual(user_count(), 0)
self.assertEqual(user_count(), 1)
# enough set
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password
self.run_reload()
self.assertEqual(user_count(), 1)
self.assertEqual(user_count(), 2)
# create user manually
self.user_mdl.objects.create_user('testuser', 'test@testing.com', 'password')
self.assertEqual(user_count(), 3)
# check it will not be created again
self.env.set('INVENTREE_ADMIN_USER', 'testuser')
self.env.set('INVENTREE_ADMIN_EMAIL', 'test@testing.com')
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password')
self.run_reload()
self.assertEqual(user_count(), 3)
# make sure to clean up
settings.TESTING_ENV = False
def test_initial_install(self):
"""Test if install of plugins on startup works"""
from plugin import registry
# Check an install run
response = registry.install_plugin_file()
self.assertEqual(response, 'first_run')
# Set dynamic setting to True and rerun to launch install
InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user)
registry.reload_plugins()
# Check that there was anotehr run
response = registry.install_plugin_file()
self.assertEqual(response, True)
def test_helpers_cfg_file(self):
# normal run - not configured
self.assertIn('InvenTree/InvenTree/config.yaml', config.get_config_file())

View File

@ -27,7 +27,7 @@ from order.api import order_api_urls
from label.api import label_api_urls
from report.api import report_api_urls
from plugin.api import plugin_api_urls
from plugin.barcode import barcode_api_urls
from users.api import user_urls
from django.conf import settings
from django.conf.urls.static import static
@ -45,20 +45,11 @@ from .views import DynamicJsView
from .views import NotificationsView
from .api import InfoView, NotFoundView
from .api import ActionPluginView
from users.api import user_urls
admin.site.site_header = "InvenTree Admin"
apipatterns = []
if settings.PLUGINS_ENABLED:
apipatterns.append(
re_path(r'^plugin/', include(plugin_api_urls))
)
apipatterns += [
apipatterns = [
re_path(r'^settings/', include(settings_api_urls)),
re_path(r'^part/', include(part_api_urls)),
re_path(r'^bom/', include(bom_api_urls)),
@ -68,13 +59,10 @@ apipatterns += [
re_path(r'^order/', include(order_api_urls)),
re_path(r'^label/', include(label_api_urls)),
re_path(r'^report/', include(report_api_urls)),
# User URLs
re_path(r'^user/', include(user_urls)),
# Plugin endpoints
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
re_path(r'^barcode/', include(barcode_api_urls)),
path('', include(plugin_api_urls)),
# Webhook enpoint
path('', include(common_api_urls)),

View File

@ -2,9 +2,53 @@
from __future__ import unicode_literals
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from .models import Build, BuildItem
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from import_export.resources import ModelResource
import import_export.widgets as widgets
from build.models import Build, BuildItem
import part.models
class BuildResource(ModelResource):
"""Class for managing import/export of Build data"""
# For some reason, we need to specify the fields individually for this ModelResource,
# but we don't for other ones.
# TODO: 2022-05-12 - Need to investigate why this is the case!
pk = Field(attribute='pk')
reference = Field(attribute='reference')
title = Field(attribute='title')
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(part.models.Part))
part_name = Field(attribute='part__full_name', readonly=True)
overdue = Field(attribute='is_overdue', readonly=True, widget=widgets.BooleanWidget())
completed = Field(attribute='completed', readonly=True)
quantity = Field(attribute='quantity')
status = Field(attribute='status')
batch = Field(attribute='batch')
notes = Field(attribute='notes')
class Meta:
models = Build
skip_unchanged = True
report_skipped = False
clean_model_instances = True
exclude = [
'lft', 'rght', 'tree_id', 'level',
]
class BuildAdmin(ImportExportModelAdmin):

View File

@ -12,13 +12,15 @@ from rest_framework import filters, generics
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
from InvenTree.api import AttachmentMixin
from InvenTree.helpers import str2bool, isNull
from InvenTree.api import AttachmentMixin, APIDownloadMixin
from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem, BuildOrderAttachment
import build.admin
import build.serializers
from build.models import Build, BuildItem, BuildOrderAttachment
from users.models import Owner
@ -71,7 +73,7 @@ class BuildFilter(rest_filters.FilterSet):
return queryset
class BuildList(generics.ListCreateAPIView):
class BuildList(APIDownloadMixin, generics.ListCreateAPIView):
""" API endpoint for accessing a list of Build objects.
- GET: Return list of objects (with filters)
@ -123,6 +125,14 @@ class BuildList(generics.ListCreateAPIView):
return queryset
def download_queryset(self, queryset, export_format):
dataset = build.admin.BuildResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_BuildOrders.{export_format}"
return DownloadFile(filedata, filename)
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)

View File

@ -17,15 +17,16 @@ import base64
from secrets import compare_digest
from datetime import datetime, timedelta
from django.apps import apps
from django.db import models, transaction
from django.db.utils import IntegrityError, OperationalError
from django.conf import settings
from django.contrib.auth.models import User, Group
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db.utils import IntegrityError, OperationalError
from django.conf import settings
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.urls import reverse
from django.utils.timezone import now
from django.contrib.humanize.templatetags.humanize import naturaltime
from djmoney.settings import CURRENCY_CHOICES
from djmoney.contrib.exchange.models import convert_money
@ -136,6 +137,19 @@ class BaseInvenTreeSetting(models.Model):
return settings
def get_kwargs(self):
"""
Construct kwargs for doing class-based settings lookup,
depending on *which* class we are.
This is necessary to abtract the settings object
from the implementing class (e.g plugins)
Subclasses should override this function to ensure the kwargs are correctly set.
"""
return {}
@classmethod
def get_setting_definition(cls, key, **kwargs):
"""
@ -257,9 +271,9 @@ class BaseInvenTreeSetting(models.Model):
plugin = kwargs.get('plugin', None)
if plugin is not None:
from plugin import InvenTreePluginBase
from plugin import InvenTreePlugin
if issubclass(plugin.__class__, InvenTreePluginBase):
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
filters['plugin'] = plugin
@ -319,11 +333,11 @@ class BaseInvenTreeSetting(models.Model):
value = setting.value
# Cast to boolean if necessary
if setting.is_bool(**kwargs):
if setting.is_bool():
value = InvenTree.helpers.str2bool(value)
# Cast to integer if necessary
if setting.is_int(**kwargs):
if setting.is_int():
try:
value = int(value)
except (ValueError, TypeError):
@ -361,9 +375,9 @@ class BaseInvenTreeSetting(models.Model):
filters['user'] = user
if plugin is not None:
from plugin import InvenTreePluginBase
from plugin import InvenTreePlugin
if issubclass(plugin.__class__, InvenTreePluginBase):
if issubclass(plugin.__class__, InvenTreePlugin):
filters['plugin'] = plugin.plugin_config()
else:
filters['plugin'] = plugin
@ -390,19 +404,19 @@ class BaseInvenTreeSetting(models.Model):
@property
def name(self):
return self.__class__.get_setting_name(self.key)
return self.__class__.get_setting_name(self.key, **self.get_kwargs())
@property
def default_value(self):
return self.__class__.get_setting_default(self.key)
return self.__class__.get_setting_default(self.key, **self.get_kwargs())
@property
def description(self):
return self.__class__.get_setting_description(self.key)
return self.__class__.get_setting_description(self.key, **self.get_kwargs())
@property
def units(self):
return self.__class__.get_setting_units(self.key)
return self.__class__.get_setting_units(self.key, **self.get_kwargs())
def clean(self, **kwargs):
"""
@ -512,12 +526,12 @@ class BaseInvenTreeSetting(models.Model):
except self.DoesNotExist:
pass
def choices(self, **kwargs):
def choices(self):
"""
Return the available choices for this setting (or None if no choices are defined)
"""
return self.__class__.get_setting_choices(self.key, **kwargs)
return self.__class__.get_setting_choices(self.key, **self.get_kwargs())
def valid_options(self):
"""
@ -531,14 +545,14 @@ class BaseInvenTreeSetting(models.Model):
return [opt[0] for opt in choices]
def is_choice(self, **kwargs):
def is_choice(self):
"""
Check if this setting is a "choice" field
"""
return self.__class__.get_setting_choices(self.key, **kwargs) is not None
return self.__class__.get_setting_choices(self.key, **self.get_kwargs()) is not None
def as_choice(self, **kwargs):
def as_choice(self):
"""
Render this setting as the "display" value of a choice field,
e.g. if the choices are:
@ -547,7 +561,7 @@ class BaseInvenTreeSetting(models.Model):
then display 'A4 paper'
"""
choices = self.get_setting_choices(self.key, **kwargs)
choices = self.get_setting_choices(self.key, **self.get_kwargs())
if not choices:
return self.value
@ -558,12 +572,80 @@ class BaseInvenTreeSetting(models.Model):
return self.value
def is_bool(self, **kwargs):
def is_model(self):
"""
Check if this setting references a model instance in the database
"""
return self.model_name() is not None
def model_name(self):
"""
Return the model name associated with this setting
"""
setting = self.get_setting_definition(self.key, **self.get_kwargs())
return setting.get('model', None)
def model_class(self):
"""
Return the model class associated with this setting, if (and only if):
- It has a defined 'model' parameter
- The 'model' parameter is of the form app.model
- The 'model' parameter has matches a known app model
"""
model_name = self.model_name()
if not model_name:
return None
try:
(app, mdl) = model_name.strip().split('.')
except ValueError:
logger.error(f"Invalid 'model' parameter for setting {self.key} : '{model_name}'")
return None
app_models = apps.all_models.get(app, None)
if app_models is None:
logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no app named '{app}'")
return None
model = app_models.get(mdl, None)
if model is None:
logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no model named '{mdl}'")
return None
# Looks like we have found a model!
return model
def api_url(self):
"""
Return the API url associated with the linked model,
if provided, and valid!
"""
model_class = self.model_class()
if model_class:
# If a valid class has been found, see if it has registered an API URL
try:
return model_class.get_api_url()
except:
pass
return None
def is_bool(self):
"""
Check if this setting is required to be a boolean value
"""
validator = self.__class__.get_setting_validator(self.key, **kwargs)
validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs())
return self.__class__.validator_is_bool(validator)
@ -576,17 +658,20 @@ class BaseInvenTreeSetting(models.Model):
return InvenTree.helpers.str2bool(self.value)
def setting_type(self, **kwargs):
def setting_type(self):
"""
Return the field type identifier for this setting object
"""
if self.is_bool(**kwargs):
if self.is_bool():
return 'boolean'
elif self.is_int(**kwargs):
elif self.is_int():
return 'integer'
elif self.is_model():
return 'related field'
else:
return 'string'
@ -603,12 +688,12 @@ class BaseInvenTreeSetting(models.Model):
return False
def is_int(self, **kwargs):
def is_int(self,):
"""
Check if the setting is required to be an integer value:
"""
validator = self.__class__.get_setting_validator(self.key, **kwargs)
validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs())
return self.__class__.validator_is_int(validator)
@ -651,88 +736,7 @@ class BaseInvenTreeSetting(models.Model):
@property
def protected(self):
return self.__class__.is_protected(self.key)
class GenericReferencedSettingClass:
"""
This mixin can be used to add reference keys to static properties
Sample:
```python
class SampleSetting(GenericReferencedSettingClass, common.models.BaseInvenTreeSetting):
class Meta:
unique_together = [
('sample', 'key'),
]
REFERENCE_NAME = 'sample'
@classmethod
def get_setting_definition(cls, key, **kwargs):
# mysampledict contains the dict with all settings for this SettingClass - this could also be a dynamic lookup
kwargs['settings'] = mysampledict
return super().get_setting_definition(key, **kwargs)
sample = models.charKey( # the name for this field is the additonal key and must be set in the Meta class an REFERENCE_NAME
max_length=256,
verbose_name=_('sample')
)
```
"""
REFERENCE_NAME = None
def _get_reference(self):
"""
Returns dict that can be used as an argument for kwargs calls.
Helps to make overriden calls generic for simple reuse.
Usage:
```python
some_random_function(argument0, kwarg1=value1, **self._get_reference())
```
"""
return {
self.REFERENCE_NAME: getattr(self, self.REFERENCE_NAME)
}
"""
We override the following class methods,
so that we can pass the modified key instance as an additional argument
"""
def clean(self, **kwargs):
kwargs[self.REFERENCE_NAME] = getattr(self, self.REFERENCE_NAME)
super().clean(**kwargs)
def is_bool(self, **kwargs):
kwargs[self.REFERENCE_NAME] = getattr(self, self.REFERENCE_NAME)
return super().is_bool(**kwargs)
@property
def name(self):
return self.__class__.get_setting_name(self.key, **self._get_reference())
@property
def default_value(self):
return self.__class__.get_setting_default(self.key, **self._get_reference())
@property
def description(self):
return self.__class__.get_setting_description(self.key, **self._get_reference())
@property
def units(self):
return self.__class__.get_setting_units(self.key, **self._get_reference())
def choices(self):
return self.__class__.get_setting_choices(self.key, **self._get_reference())
return self.__class__.is_protected(self.key, **self.get_kwargs())
def settings_group_options():
@ -1558,6 +1562,16 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
return self.__class__.get_setting(self.key, user=self.user)
def get_kwargs(self):
"""
Explicit kwargs required to uniquely identify a particular setting object,
in addition to the 'key' parameter
"""
return {
'user': self.user,
}
class PriceBreak(models.Model):
"""

View File

@ -108,7 +108,7 @@ class NotificationMethod:
return False
# Check if method globally enabled
plg_instance = registry.plugins.get(plg_cls.PLUGIN_NAME.lower())
plg_instance = registry.plugins.get(plg_cls.NAME.lower())
if plg_instance and not plg_instance.get_setting(self.GLOBAL_SETTING):
return True

View File

@ -28,6 +28,10 @@ class SettingsSerializer(InvenTreeModelSerializer):
choices = serializers.SerializerMethodField()
model_name = serializers.CharField(read_only=True)
api_url = serializers.CharField(read_only=True)
def get_choices(self, obj):
"""
Returns the choices available for a given item
@ -75,6 +79,8 @@ class GlobalSettingsSerializer(SettingsSerializer):
'description',
'type',
'choices',
'model_name',
'api_url',
]
@ -96,6 +102,8 @@ class UserSettingsSerializer(SettingsSerializer):
'user',
'type',
'choices',
'model_name',
'api_url',
]
@ -124,6 +132,8 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
'description',
'type',
'choices',
'model_name',
'api_url',
]
# set Meta class

View File

@ -10,7 +10,8 @@ from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.helpers import str2bool
from plugin.models import NotificationUserSetting
from plugin.models import NotificationUserSetting, PluginConfig
from plugin import registry
from .models import InvenTreeSetting, InvenTreeUserSetting, WebhookEndpoint, WebhookMessage, NotificationEntry
from .api import WebhookView
@ -477,15 +478,36 @@ class PluginSettingsApiTest(InvenTreeAPITestCase):
self.get(url, expected_code=200)
def test_invalid_plugin_slug(self):
"""Test that an invalid plugin slug returns a 404"""
def test_valid_plugin_slug(self):
"""Test that an valid plugin slug runs through"""
# load plugin configs
fixtures = PluginConfig.objects.all()
if not fixtures:
registry.reload_plugins()
fixtures = PluginConfig.objects.all()
# get data
url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': 'API_KEY'})
response = self.get(url, expected_code=200)
# check the right setting came through
self.assertTrue(response.data['key'], 'API_KEY')
self.assertTrue(response.data['plugin'], 'sample')
self.assertTrue(response.data['type'], 'string')
self.assertTrue(response.data['description'], 'Key required for accessing external API')
# Failure mode tests
# Non - exsistant plugin
url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'doesnotexist', 'key': 'doesnotmatter'})
response = self.get(url, expected_code=404)
self.assertIn("Plugin 'doesnotexist' not installed", str(response.data))
# Wrong key
url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': 'doesnotexsist'})
response = self.get(url, expected_code=404)
self.assertIn("Plugin 'sample' has no setting matching 'doesnotexsist'", str(response.data))
def test_invalid_setting_key(self):
"""Test that an invalid setting key returns a 404"""
...

View File

@ -156,7 +156,7 @@ class LabelPrintMixin:
# Offload a background task to print the provided label
offload_task(
'plugin.events.print_label',
'plugin.base.label.label.print_label',
plugin.plugin_slug(),
image,
label_instance=label_instance,

View File

@ -7,13 +7,12 @@ import os
from django.conf import settings
from django.apps import apps
from django.urls import reverse
from django.core.exceptions import ValidationError
from InvenTree.helpers import validateFilterString
from InvenTree.api_tester import InvenTreeAPITestCase
from .models import StockItemLabel, StockLocationLabel, PartLabel
from .models import StockItemLabel, StockLocationLabel
from stock.models import StockItem
@ -86,13 +85,3 @@ class LabelTest(InvenTreeAPITestCase):
with self.assertRaises(ValidationError):
validateFilterString(bad_filter_string, model=StockItem)
def test_label_rendering(self):
"""Test label rendering"""
labels = PartLabel.objects.all()
part = PartLabel.objects.first()
for label in labels:
url = reverse('api-part-label-print', kwargs={'pk': label.pk})
self.get(f'{url}?parts={part.pk}', expected_code=200)

View File

@ -5,8 +5,9 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from import_export.resources import ModelResource
from import_export.fields import Field
from import_export.resources import ModelResource
import import_export.widgets as widgets
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderExtraLine
from .models import SalesOrder, SalesOrderLineItem, SalesOrderExtraLine
@ -92,6 +93,23 @@ class SalesOrderAdmin(ImportExportModelAdmin):
autocomplete_fields = ('customer',)
class PurchaseOrderResource(ModelResource):
"""
Class for managing import / export of PurchaseOrder data
"""
# Add number of line items
line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True)
# Is this order overdue?
overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True)
class Meta:
model = PurchaseOrder
skip_unchanged = True
clean_model_instances = True
class PurchaseOrderLineItemResource(ModelResource):
""" Class for managing import / export of PurchaseOrderLineItem data """
@ -117,6 +135,23 @@ class PurchaseOrderExtraLineResource(ModelResource):
model = PurchaseOrderExtraLine
class SalesOrderResource(ModelResource):
"""
Class for managing import / export of SalesOrder data
"""
# Add number of line items
line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True)
# Is this order overdue?
overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True)
class Meta:
model = SalesOrder
skip_unchanged = True
clean_model_instances = True
class SalesOrderLineItemResource(ModelResource):
"""
Class for managing import / export of SalesOrderLineItem data

View File

@ -17,10 +17,11 @@ from company.models import SupplierPart
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import str2bool, DownloadFile
from InvenTree.api import AttachmentMixin
from InvenTree.api import AttachmentMixin, APIDownloadMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
from order.admin import PurchaseOrderLineItemResource
from order.admin import PurchaseOrderResource, PurchaseOrderLineItemResource
from order.admin import SalesOrderResource
import order.models as models
import order.serializers as serializers
from part.models import Part
@ -110,7 +111,7 @@ class PurchaseOrderFilter(rest_filters.FilterSet):
]
class PurchaseOrderList(generics.ListCreateAPIView):
class PurchaseOrderList(APIDownloadMixin, generics.ListCreateAPIView):
""" API endpoint for accessing a list of PurchaseOrder objects
- GET: Return list of PurchaseOrder objects (with filters)
@ -160,6 +161,15 @@ class PurchaseOrderList(generics.ListCreateAPIView):
return queryset
def download_queryset(self, queryset, export_format):
dataset = PurchaseOrderResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_PurchaseOrders.{export_format}"
return DownloadFile(filedata, filename)
def filter_queryset(self, queryset):
# Perform basic filtering
@ -407,7 +417,7 @@ class PurchaseOrderLineItemFilter(rest_filters.FilterSet):
return queryset
class PurchaseOrderLineItemList(generics.ListCreateAPIView):
class PurchaseOrderLineItemList(APIDownloadMixin, generics.ListCreateAPIView):
""" API endpoint for accessing a list of PurchaseOrderLineItem objects
- GET: Return a list of PurchaseOrder Line Item objects
@ -460,25 +470,19 @@ class PurchaseOrderLineItemList(generics.ListCreateAPIView):
return queryset
def download_queryset(self, queryset, export_format):
dataset = PurchaseOrderLineItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_PurchaseOrderItems.{export_format}"
return DownloadFile(filedata, filename)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
# Check if we wish to export the queried data to a file
export_format = request.query_params.get('export', None)
if export_format:
export_format = str(export_format).strip().lower()
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
dataset = PurchaseOrderLineItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_PurchaseOrderData.{export_format}"
return DownloadFile(filedata, filename)
page = self.paginate_queryset(queryset)
if page is not None:
@ -580,7 +584,7 @@ class SalesOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, Attachme
serializer_class = serializers.SalesOrderAttachmentSerializer
class SalesOrderList(generics.ListCreateAPIView):
class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView):
"""
API endpoint for accessing a list of SalesOrder objects.
@ -630,6 +634,15 @@ class SalesOrderList(generics.ListCreateAPIView):
return queryset
def download_queryset(self, queryset, export_format):
dataset = SalesOrderResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_SalesOrders.{export_format}"
return DownloadFile(filedata, filename)
def filter_queryset(self, queryset):
"""
Perform custom filtering operations on the SalesOrder queryset.

View File

@ -4,8 +4,8 @@ from __future__ import unicode_literals
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from import_export.resources import ModelResource
from import_export.fields import Field
from import_export.resources import ModelResource
import import_export.widgets as widgets
from company.models import SupplierPart

View File

@ -49,7 +49,7 @@ from . import serializers as part_serializers
from InvenTree.helpers import str2bool, isNull, increment
from InvenTree.helpers import DownloadFile
from InvenTree.api import AttachmentMixin
from InvenTree.api import AttachmentMixin, APIDownloadMixin
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
@ -847,7 +847,7 @@ class PartFilter(rest_filters.FilterSet):
virtual = rest_filters.BooleanFilter()
class PartList(generics.ListCreateAPIView):
class PartList(APIDownloadMixin, generics.ListCreateAPIView):
"""
API endpoint for accessing a list of Part objects
@ -897,6 +897,14 @@ class PartList(generics.ListCreateAPIView):
return self.serializer_class(*args, **kwargs)
def download_queryset(self, queryset, export_format):
dataset = PartResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Parts.{export_format}"
return DownloadFile(filedata, filename)
def list(self, request, *args, **kwargs):
"""
Overide the 'list' method, as the PartCategory objects are
@ -908,22 +916,6 @@ class PartList(generics.ListCreateAPIView):
queryset = self.filter_queryset(self.get_queryset())
# Check if we wish to export the queried data to a file.
# If so, skip pagination!
export_format = request.query_params.get('export', None)
if export_format:
export_format = str(export_format).strip().lower()
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
dataset = PartResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Parts.{export_format}"
return DownloadFile(filedata, filename)
page = self.paginate_queryset(queryset)
if page is not None:

View File

@ -2233,7 +2233,7 @@ class Part(MPTTModel):
for child in children:
parts.append(child)
# Immediate parent
# Immediate parent, and siblings
if self.variant_of:
parts.append(self.variant_of)

View File

@ -3,17 +3,14 @@ Utility file to enable simper imports
"""
from .registry import registry
from .plugin import InvenTreePluginBase
from .integration import IntegrationPluginBase
from .action import ActionPlugin
from .plugin import InvenTreePlugin, IntegrationPluginBase
from .helpers import MixinNotImplementedError, MixinImplementationError
__all__ = [
'ActionPlugin',
'IntegrationPluginBase',
'InvenTreePluginBase',
'registry',
'InvenTreePlugin',
IntegrationPluginBase,
'MixinNotImplementedError',
'MixinImplementationError',
]

View File

@ -1,23 +0,0 @@
# -*- coding: utf-8 -*-
"""Class for ActionPlugin"""
import logging
import warnings
from plugin.builtin.action.mixins import ActionMixin
import plugin.integration
logger = logging.getLogger("inventree")
class ActionPlugin(ActionMixin, plugin.integration.IntegrationPluginBase):
"""
Legacy action definition - will be replaced
Please use the new Integration Plugin API and the Action mixin
"""
# TODO @matmair remove this with InvenTree 0.7.0
def __init__(self, user=None, data=None):
warnings.warn("using the ActionPlugin is depreceated", DeprecationWarning)
super().__init__()
self.init(user, data)

View File

@ -5,6 +5,7 @@ JSON API for the plugin app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.urls import include, re_path
from rest_framework import generics
@ -16,6 +17,8 @@ from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from common.api import GlobalSettingsPermissions
from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.action.api import ActionPluginView
from plugin.models import PluginConfig, PluginSetting
import plugin.serializers as PluginSerializers
from plugin.registry import registry
@ -141,7 +144,8 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
plugin = registry.get_plugin(plugin_slug)
if plugin is None:
raise NotFound(detail=f"Plugin '{plugin_slug}' not found")
# This only occurs if the plugin mechanism broke
raise NotFound(detail=f"Plugin '{plugin_slug}' not found") # pragma: no cover
settings = getattr(plugin, 'SETTINGS', {})
@ -157,6 +161,11 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
plugin_api_urls = [
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
re_path(r'^barcode/', include(barcode_api_urls)),
]
general_plugin_api_urls = [
# Plugin settings URLs
re_path(r'^settings/', include([
@ -174,3 +183,8 @@ plugin_api_urls = [
# Anything else
re_path(r'^.*$', PluginList.as_view(), name='api-plugin-list'),
]
if settings.PLUGINS_ENABLED:
plugin_api_urls.append(
re_path(r'^plugin/', include(general_plugin_api_urls))
)

View File

View File

View File

@ -0,0 +1,45 @@
"""APIs for action plugins"""
from django.utils.translation import gettext_lazy as _
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from plugin import registry
class ActionPluginView(APIView):
"""
Endpoint for running custom action plugins.
"""
permission_classes = [
permissions.IsAuthenticated,
]
def post(self, request, *args, **kwargs):
action = request.data.get('action', None)
data = request.data.get('data', None)
if action is None:
return Response({
'error': _("No action specified")
})
action_plugins = registry.with_mixin('action')
for plugin in action_plugins:
if plugin.action_name() == action:
# TODO @matmair use easier syntax once InvenTree 0.7.0 is released
plugin.init(request.user, data=data)
plugin.perform_action()
return Response(plugin.get_response())
# If we got to here, no matching action was found
return Response({
'error': _("No matching action found"),
"action": action,
})

View File

@ -15,16 +15,17 @@ class ActionMixin:
"""
MIXIN_NAME = 'Actions'
def __init__(self):
def __init__(self, user=None, data=None):
super().__init__()
self.add_mixin('action', True, __class__)
self.init(user, data)
def action_name(self):
"""
Action name for this plugin.
If the ACTION_NAME parameter is empty,
uses the PLUGIN_NAME instead.
uses the NAME instead.
"""
if self.ACTION_NAME:
return self.ACTION_NAME

View File

@ -1,34 +1,38 @@
""" Unit tests for action plugins """
from django.test import TestCase
from django.contrib.auth import get_user_model
from plugin.action import ActionPlugin
from plugin import InvenTreePlugin
from plugin.mixins import ActionMixin
class ActionPluginTests(TestCase):
""" Tests for ActionPlugin """
class ActionMixinTests(TestCase):
""" Tests for ActionMixin """
ACTION_RETURN = 'a action was performed'
def setUp(self):
self.plugin = ActionPlugin('user')
class SimplePlugin(ActionMixin, InvenTreePlugin):
pass
self.plugin = SimplePlugin('user')
class TestActionPlugin(ActionPlugin):
class TestActionPlugin(ActionMixin, InvenTreePlugin):
"""a action plugin"""
ACTION_NAME = 'abc123'
def perform_action(self):
return ActionPluginTests.ACTION_RETURN + 'action'
return ActionMixinTests.ACTION_RETURN + 'action'
def get_result(self):
return ActionPluginTests.ACTION_RETURN + 'result'
return ActionMixinTests.ACTION_RETURN + 'result'
def get_info(self):
return ActionPluginTests.ACTION_RETURN + 'info'
return ActionMixinTests.ACTION_RETURN + 'info'
self.action_plugin = TestActionPlugin('user')
class NameActionPlugin(ActionPlugin):
PLUGIN_NAME = 'Aplugin'
class NameActionPlugin(ActionMixin, InvenTreePlugin):
NAME = 'Aplugin'
self.action_name = NameActionPlugin('user')
@ -59,3 +63,32 @@ class ActionPluginTests(TestCase):
"result": self.ACTION_RETURN + 'result',
"info": self.ACTION_RETURN + 'info',
})
class APITests(TestCase):
""" Tests for action api """
def setUp(self):
# Create a user for auth
user = get_user_model()
self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password')
def test_post_errors(self):
"""Check the possible errors with post"""
# Test empty request
response = self.client.post('/api/action/')
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data,
{'error': 'No action specified'}
)
# Test non-exsisting action
response = self.client.post('/api/action/', data={'action': "nonexsisting"})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data,
{'error': 'No matching action found', 'action': 'nonexsisting'}
)

View File

@ -11,7 +11,7 @@ from stock.models import StockItem
from stock.serializers import StockItemSerializer
from plugin.builtin.barcodes.inventree_barcode import InvenTreeBarcodePlugin
from plugin.builtin.barcodes.mixins import hash_barcode
from plugin.base.barcodes.mixins import hash_barcode
from plugin import registry
@ -237,7 +237,7 @@ class BarcodeAssign(APIView):
barcode_api_urls = [
# Link a barcode to a part
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
# Catch-all performs barcode 'scan'

View File

View File

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

View File

@ -0,0 +1,29 @@
"""Plugin mixin class for events"""
from plugin.helpers import MixinNotImplementedError
class EventMixin:
"""
Mixin that provides support for responding to triggered events.
Implementing classes must provide a "process_event" function:
"""
def process_event(self, event, *args, **kwargs):
"""
Function to handle events
Must be overridden by plugin
"""
# Default implementation does not do anything
raise MixinNotImplementedError
class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'Events'
def __init__(self):
super().__init__()
self.add_mixin('events', True, __class__)

View File

@ -11,9 +11,8 @@ from django.db.utils import OperationalError, ProgrammingError
import InvenTree.helpers
from plugin.helpers import MixinImplementationError, MixinNotImplementedError
from plugin.helpers import MixinImplementationError, MixinNotImplementedError, render_template
from plugin.models import PluginConfig, PluginSetting
from plugin.template import render_template
from plugin.urls import PLUGIN_BASE
@ -238,32 +237,6 @@ class ScheduleMixin:
logger.warning("unregister_tasks failed, database not ready")
class EventMixin:
"""
Mixin that provides support for responding to triggered events.
Implementing classes must provide a "process_event" function:
"""
def process_event(self, event, *args, **kwargs):
"""
Function to handle events
Must be overridden by plugin
"""
# Default implementation does not do anything
raise MixinNotImplementedError
class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'Events'
def __init__(self):
super().__init__()
self.add_mixin('events', True, __class__)
class UrlsMixin:
"""
Mixin that enables custom URLs for the plugin
@ -396,42 +369,6 @@ class AppMixin:
return True
class LabelPrintingMixin:
"""
Mixin which enables direct printing of stock labels.
Each plugin must provide a PLUGIN_NAME attribute, which is used to uniquely identify the printer.
The plugin must also implement the print_label() function
"""
class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'Label printing'
def __init__(self): # pragma: no cover
super().__init__()
self.add_mixin('labels', True, __class__)
def print_label(self, label, **kwargs):
"""
Callback to print a single label
Arguments:
label: A black-and-white pillow Image object
kwargs:
length: The length of the label (in mm)
width: The width of the label (in mm)
"""
# Unimplemented (to be implemented by the particular plugin class)
... # pragma: no cover
class APICallMixin:
"""
Mixin that enables easier API calls for a plugin
@ -447,15 +384,15 @@ class APICallMixin:
Example:
```
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
from plugin.mixins import APICallMixin, SettingsMixin
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, IntegrationPluginBase):
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, InvenTreePlugin):
'''
A small api call sample
'''
PLUGIN_NAME = "Sample API Caller"
NAME = "Sample API Caller"
SETTINGS = {
'API_TOKEN': {
@ -496,9 +433,9 @@ class APICallMixin:
def has_api_call(self):
"""Is the mixin ready to call external APIs?"""
if not bool(self.API_URL_SETTING):
raise ValueError("API_URL_SETTING must be defined")
raise MixinNotImplementedError("API_URL_SETTING must be defined")
if not bool(self.API_TOKEN_SETTING):
raise ValueError("API_TOKEN_SETTING must be defined")
raise MixinNotImplementedError("API_TOKEN_SETTING must be defined")
return True
@property

View File

@ -1,17 +1,14 @@
""" Unit tests for integration plugins """
""" Unit tests for base mixins for plugins """
from django.test import TestCase
from django.conf import settings
from django.urls import include, re_path
from django.contrib.auth import get_user_model
from datetime import datetime
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
from plugin.urls import PLUGIN_BASE
from plugin.samples.integration.sample import SampleIntegrationPlugin
from plugin.helpers import MixinNotImplementedError
class BaseMixinDefinition:
@ -30,11 +27,11 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
TEST_SETTINGS = {'SETTING1': {'default': '123', }}
def setUp(self):
class SettingsCls(SettingsMixin, IntegrationPluginBase):
class SettingsCls(SettingsMixin, InvenTreePlugin):
SETTINGS = self.TEST_SETTINGS
self.mixin = SettingsCls()
class NoSettingsCls(SettingsMixin, IntegrationPluginBase):
class NoSettingsCls(SettingsMixin, InvenTreePlugin):
pass
self.mixin_nothing = NoSettingsCls()
@ -65,13 +62,13 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
MIXIN_ENABLE_CHECK = 'has_urls'
def setUp(self):
class UrlsCls(UrlsMixin, IntegrationPluginBase):
class UrlsCls(UrlsMixin, InvenTreePlugin):
def test():
return 'ccc'
URLS = [re_path('testpath', test, name='test'), ]
self.mixin = UrlsCls()
class NoUrlsCls(UrlsMixin, IntegrationPluginBase):
class NoUrlsCls(UrlsMixin, InvenTreePlugin):
pass
self.mixin_nothing = NoUrlsCls()
@ -104,7 +101,7 @@ class AppMixinTest(BaseMixinDefinition, TestCase):
MIXIN_ENABLE_CHECK = 'has_app'
def setUp(self):
class TestCls(AppMixin, IntegrationPluginBase):
class TestCls(AppMixin, InvenTreePlugin):
pass
self.mixin = TestCls()
@ -119,30 +116,32 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
MIXIN_ENABLE_CHECK = 'has_naviation'
def setUp(self):
class NavigationCls(NavigationMixin, IntegrationPluginBase):
class NavigationCls(NavigationMixin, InvenTreePlugin):
NAVIGATION = [
{'name': 'aa', 'link': 'plugin:test:test_view'},
]
NAVIGATION_TAB_NAME = 'abcd1'
self.mixin = NavigationCls()
class NothingNavigationCls(NavigationMixin, IntegrationPluginBase):
class NothingNavigationCls(NavigationMixin, InvenTreePlugin):
pass
self.nothing_mixin = NothingNavigationCls()
def test_function(self):
# check right configuration
self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}, ])
# check wrong links fails
with self.assertRaises(NotImplementedError):
class NavigationCls(NavigationMixin, IntegrationPluginBase):
NAVIGATION = ['aa', 'aa']
NavigationCls()
# navigation name
self.assertEqual(self.mixin.navigation_name, 'abcd1')
self.assertEqual(self.nothing_mixin.navigation_name, '')
def test_fail(self):
# check wrong links fails
with self.assertRaises(NotImplementedError):
class NavigationCls(NavigationMixin, InvenTreePlugin):
NAVIGATION = ['aa', 'aa']
NavigationCls()
class APICallMixinTest(BaseMixinDefinition, TestCase):
MIXIN_HUMAN_NAME = 'API calls'
@ -150,8 +149,8 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
MIXIN_ENABLE_CHECK = 'has_api_call'
def setUp(self):
class MixinCls(APICallMixin, SettingsMixin, IntegrationPluginBase):
PLUGIN_NAME = "Sample API Caller"
class MixinCls(APICallMixin, SettingsMixin, InvenTreePlugin):
NAME = "Sample API Caller"
SETTINGS = {
'API_TOKEN': {
@ -167,22 +166,23 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
API_URL_SETTING = 'API_URL'
API_TOKEN_SETTING = 'API_TOKEN'
def get_external_url(self):
def get_external_url(self, simple: bool = True):
'''
returns data from the sample endpoint
'''
return self.api_call('api/users/2')
return self.api_call('api/users/2', simple_response=simple)
self.mixin = MixinCls()
class WrongCLS(APICallMixin, IntegrationPluginBase):
class WrongCLS(APICallMixin, InvenTreePlugin):
pass
self.mixin_wrong = WrongCLS()
class WrongCLS2(APICallMixin, IntegrationPluginBase):
class WrongCLS2(APICallMixin, InvenTreePlugin):
API_URL_SETTING = 'test'
self.mixin_wrong2 = WrongCLS2()
def test_function(self):
def test_base_setup(self):
"""Test that the base settings work"""
# check init
self.assertTrue(self.mixin.has_api_call)
# api_url
@ -192,6 +192,8 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
headers = self.mixin.api_headers
self.assertEqual(headers, {'Bearer': '', 'Content-Type': 'application/json'})
def test_args(self):
"""Test that building up args work"""
# api_build_url_args
# 1 arg
result = self.mixin.api_build_url_args({'a': 'b'})
@ -203,88 +205,42 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
result = self.mixin.api_build_url_args({'a': 'b', 'c': ['d', 'e', 'f', ]})
self.assertEqual(result, '?a=b&c=d,e,f')
def test_api_call(self):
"""Test that api calls work"""
# api_call
result = self.mixin.get_external_url()
self.assertTrue(result)
self.assertIn('data', result,)
# api_call without json conversion
result = self.mixin.get_external_url(False)
self.assertTrue(result)
self.assertEqual(result.reason, 'OK')
# api_call with full url
result = self.mixin.api_call('https://reqres.in/api/users/2', endpoint_is_url=True)
self.assertTrue(result)
# api_call with post and data
result = self.mixin.api_call(
'api/users/',
data={"name": "morpheus", "job": "leader"},
method='POST'
)
self.assertTrue(result)
self.assertEqual(result['name'], 'morpheus')
# api_call with filter
result = self.mixin.api_call('api/users', url_args={'page': '2'})
self.assertTrue(result)
self.assertEqual(result['page'], 2)
def test_function_errors(self):
"""Test function errors"""
# wrongly defined plugins should not load
with self.assertRaises(ValueError):
with self.assertRaises(MixinNotImplementedError):
self.mixin_wrong.has_api_call()
# cover wrong token setting
with self.assertRaises(ValueError):
self.mixin_wrong.has_api_call()
class IntegrationPluginBaseTests(TestCase):
""" Tests for IntegrationPluginBase """
def setUp(self):
self.plugin = IntegrationPluginBase()
class SimpeIntegrationPluginBase(IntegrationPluginBase):
PLUGIN_NAME = 'SimplePlugin'
self.plugin_simple = SimpeIntegrationPluginBase()
class NameIntegrationPluginBase(IntegrationPluginBase):
PLUGIN_NAME = 'Aplugin'
PLUGIN_SLUG = 'a'
PLUGIN_TITLE = 'a titel'
PUBLISH_DATE = "1111-11-11"
AUTHOR = 'AA BB'
DESCRIPTION = 'A description'
VERSION = '1.2.3a'
WEBSITE = 'http://aa.bb/cc'
LICENSE = 'MIT'
self.plugin_name = NameIntegrationPluginBase()
self.plugin_sample = SampleIntegrationPlugin()
def test_action_name(self):
"""check the name definition possibilities"""
# plugin_name
self.assertEqual(self.plugin.plugin_name(), '')
self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin')
self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin')
# is_sampe
self.assertEqual(self.plugin.is_sample, False)
self.assertEqual(self.plugin_sample.is_sample, True)
# slug
self.assertEqual(self.plugin.slug, '')
self.assertEqual(self.plugin_simple.slug, 'simpleplugin')
self.assertEqual(self.plugin_name.slug, 'a')
# human_name
self.assertEqual(self.plugin.human_name, '')
self.assertEqual(self.plugin_simple.human_name, 'SimplePlugin')
self.assertEqual(self.plugin_name.human_name, 'a titel')
# description
self.assertEqual(self.plugin.description, '')
self.assertEqual(self.plugin_simple.description, 'SimplePlugin')
self.assertEqual(self.plugin_name.description, 'A description')
# author
self.assertEqual(self.plugin_name.author, 'AA BB')
# pub_date
self.assertEqual(self.plugin_name.pub_date, datetime(1111, 11, 11, 0, 0))
# version
self.assertEqual(self.plugin.version, None)
self.assertEqual(self.plugin_simple.version, None)
self.assertEqual(self.plugin_name.version, '1.2.3a')
# website
self.assertEqual(self.plugin.website, None)
self.assertEqual(self.plugin_simple.website, None)
self.assertEqual(self.plugin_name.website, 'http://aa.bb/cc')
# license
self.assertEqual(self.plugin.license, None)
self.assertEqual(self.plugin_simple.license, None)
self.assertEqual(self.plugin_name.license, 'MIT')
with self.assertRaises(MixinNotImplementedError):
self.mixin_wrong2.has_api_call()

View File

View File

@ -0,0 +1,53 @@
"""Functions to print a label to a mixin printer"""
import logging
from django.utils.translation import gettext_lazy as _
from plugin.registry import registry
import common.notifications
logger = logging.getLogger('inventree')
def print_label(plugin_slug, label_image, label_instance=None, user=None):
"""
Print label with the provided plugin.
This task is nominally handled by the background worker.
If the printing fails (throws an exception) then the user is notified.
Arguments:
plugin_slug: The unique slug (key) of the plugin
label_image: A PIL.Image image object to be printed
"""
logger.info(f"Plugin '{plugin_slug}' is printing a label")
plugin = registry.plugins.get(plugin_slug, None)
if plugin is None:
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
return
try:
plugin.print_label(label_image, width=label_instance.width, height=label_instance.height)
except Exception as e:
# Plugin threw an error - notify the user who attempted to print
ctx = {
'name': _('Label printing failed'),
'message': str(e),
}
logger.error(f"Label printing failed: Sending notification to user '{user}'")
# Throw an error against the plugin instance
common.notifications.trigger_notifaction(
plugin.plugin_config(),
'label.printing_failed',
targets=[user],
context=ctx,
delivery_methods=[common.notifications.UIMessageNotification]
)

View File

@ -0,0 +1,39 @@
"""Plugin mixin classes for label plugins"""
from plugin.helpers import MixinNotImplementedError
class LabelPrintingMixin:
"""
Mixin which enables direct printing of stock labels.
Each plugin must provide a NAME attribute, which is used to uniquely identify the printer.
The plugin must also implement the print_label() function
"""
class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'Label printing'
def __init__(self): # pragma: no cover
super().__init__()
self.add_mixin('labels', True, __class__)
def print_label(self, label, **kwargs):
"""
Callback to print a single label
Arguments:
label: A black-and-white pillow Image object
kwargs:
length: The length of the label (in mm)
width: The width of the label (in mm)
"""
# Unimplemented (to be implemented by the particular plugin class)
MixinNotImplementedError('This Plugin must implement a `print_label` method')

View File

@ -1,15 +1,16 @@
# -*- coding: utf-8 -*-
"""sample implementation for ActionPlugin"""
from plugin.action import ActionPlugin
"""sample implementation for ActionMixin"""
from plugin import InvenTreePlugin
from plugin.mixins import ActionMixin
class SimpleActionPlugin(ActionPlugin):
class SimpleActionPlugin(ActionMixin, InvenTreePlugin):
"""
An EXTREMELY simple action plugin which demonstrates
the capability of the ActionPlugin class
the capability of the ActionMixin class
"""
PLUGIN_NAME = "SimpleActionPlugin"
NAME = "SimpleActionPlugin"
ACTION_NAME = "simple"
def perform_action(self):

View File

@ -13,7 +13,7 @@ references model objects actually exist in the database.
import json
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
from plugin.mixins import BarcodeMixin
from stock.models import StockItem, StockLocation
@ -22,9 +22,9 @@ from part.models import Part
from rest_framework.exceptions import ValidationError
class InvenTreeBarcodePlugin(BarcodeMixin, IntegrationPluginBase):
class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
PLUGIN_NAME = "InvenTreeBarcode"
NAME = "InvenTreeBarcode"
def validate(self):
"""

View File

@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _
from allauth.account.models import EmailAddress
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
from plugin.mixins import BulkNotificationMethod, SettingsMixin
import InvenTree.tasks
@ -15,12 +15,12 @@ class PlgMixin:
return CoreNotificationsPlugin
class CoreNotificationsPlugin(SettingsMixin, IntegrationPluginBase):
class CoreNotificationsPlugin(SettingsMixin, InvenTreePlugin):
"""
Core notification methods for InvenTree
"""
PLUGIN_NAME = "CoreNotificationsPlugin"
NAME = "CoreNotificationsPlugin"
AUTHOR = _('InvenTree contributors')
DESCRIPTION = _('Integrated outgoing notificaton methods')

View File

@ -1,239 +1,9 @@
"""
Functions for triggering and responding to server side events
Import helper for events
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from plugin.base.event.events import trigger_event
import logging
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_save, post_delete
from django.dispatch.dispatcher import receiver
from common.models import InvenTreeSetting
import common.notifications
from InvenTree.ready import canAppAccessDatabase, isImportingData
from InvenTree.tasks import offload_task
from plugin.registry import registry
logger = logging.getLogger('inventree')
def trigger_event(event, *args, **kwargs):
"""
Trigger an event with optional arguments.
This event will be stored in the database,
and the worker will respond to it later on.
"""
if not settings.PLUGINS_ENABLED:
# Do nothing if plugins are not enabled
return
if not canAppAccessDatabase():
logger.debug(f"Ignoring triggered event '{event}' - database not ready")
return
logger.debug(f"Event triggered: '{event}'")
offload_task(
'plugin.events.register_event',
event,
*args,
**kwargs
)
def register_event(event, *args, **kwargs):
"""
Register the event with any interested plugins.
Note: This function is processed by the background worker,
as it performs multiple database access operations.
"""
logger.debug(f"Registering triggered event: '{event}'")
# Determine if there are any plugins which are interested in responding
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'):
with transaction.atomic():
for slug, plugin in registry.plugins.items():
if plugin.mixin_enabled('events'):
config = plugin.plugin_config()
if config and config.active:
logger.debug(f"Registering callback for plugin '{slug}'")
# Offload a separate task for each plugin
offload_task(
'plugin.events.process_event',
slug,
event,
*args,
**kwargs
)
def process_event(plugin_slug, event, *args, **kwargs):
"""
Respond to a triggered event.
This function is run by the background worker process.
This function may queue multiple functions to be handled by the background worker.
"""
logger.info(f"Plugin '{plugin_slug}' is processing triggered event '{event}'")
plugin = registry.plugins.get(plugin_slug, None)
if plugin is None:
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
return
plugin.process_event(event, *args, **kwargs)
def allow_table_event(table_name):
"""
Determine if an automatic event should be fired for a given table.
We *do not* want events to be fired for some tables!
"""
if isImportingData():
# Prevent table events during the data import process
return False
table_name = table_name.lower().strip()
# Ignore any tables which start with these prefixes
ignore_prefixes = [
'account_',
'auth_',
'authtoken_',
'django_',
'error_',
'exchange_',
'otp_',
'plugin_',
'socialaccount_',
'user_',
'users_',
]
if any([table_name.startswith(prefix) for prefix in ignore_prefixes]):
return False
ignore_tables = [
'common_notificationentry',
'common_webhookendpoint',
'common_webhookmessage',
]
if table_name in ignore_tables:
return False
return True
@receiver(post_save)
def after_save(sender, instance, created, **kwargs):
"""
Trigger an event whenever a database entry is saved
"""
table = sender.objects.model._meta.db_table
instance_id = getattr(instance, 'id', None)
if instance_id is None:
return
if not allow_table_event(table):
return
if created:
trigger_event(
f'{table}.created',
id=instance.id,
model=sender.__name__,
)
else:
trigger_event(
f'{table}.saved',
id=instance.id,
model=sender.__name__,
)
@receiver(post_delete)
def after_delete(sender, instance, **kwargs):
"""
Trigger an event whenever a database entry is deleted
"""
table = sender.objects.model._meta.db_table
if not allow_table_event(table):
return
trigger_event(
f'{table}.deleted',
model=sender.__name__,
)
def print_label(plugin_slug, label_image, label_instance=None, user=None):
"""
Print label with the provided plugin.
This task is nominally handled by the background worker.
If the printing fails (throws an exception) then the user is notified.
Arguments:
plugin_slug: The unique slug (key) of the plugin
label_image: A PIL.Image image object to be printed
"""
logger.info(f"Plugin '{plugin_slug}' is printing a label")
plugin = registry.plugins.get(plugin_slug, None)
if plugin is None:
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
return
try:
plugin.print_label(label_image, width=label_instance.width, height=label_instance.height)
except Exception as e:
# Plugin threw an error - notify the user who attempted to print
ctx = {
'name': _('Label printing failed'),
'message': str(e),
}
logger.error(f"Label printing failed: Sending notification to user '{user}'")
# Throw an error against the plugin instance
common.notifications.trigger_notifaction(
plugin.plugin_config(),
'label.printing_failed',
targets=[user],
context=ctx,
delivery_methods=[common.notifications.UIMessageNotification]
)
__all__ = [
trigger_event,
]

View File

@ -8,12 +8,17 @@ import sysconfig
import traceback
import inspect
import pkgutil
import logging
from django import template
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
from django.db.utils import IntegrityError
logger = logging.getLogger('inventree')
# region logging / errors
class IntegrationPluginError(Exception):
"""
@ -200,7 +205,7 @@ def get_plugins(pkg, baseclass):
Return a list of all modules under a given package.
- Modules must be a subclass of the provided 'baseclass'
- Modules must have a non-empty PLUGIN_NAME parameter
- Modules must have a non-empty NAME parameter
"""
plugins = []
@ -212,8 +217,32 @@ def get_plugins(pkg, baseclass):
# Iterate through each class in the module
for item in get_classes(mod):
plugin = item[1]
if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME:
if issubclass(plugin, baseclass) and plugin.NAME:
plugins.append(plugin)
return plugins
# endregion
# region templates
def render_template(plugin, template_file, context=None):
"""
Locate and render a template file, available in the global template context.
"""
try:
tmp = template.loader.get_template(template_file)
except template.TemplateDoesNotExist:
logger.error(f"Plugin {plugin.slug} could not locate template '{template_file}'")
return f"""
<div class='alert alert-block alert-danger'>
Template file <em>{template_file}</em> does not exist.
</div>
"""
# Render with the provided context
html = tmp.render(context)
return html
# endregion

View File

@ -1,261 +0,0 @@
# -*- coding: utf-8 -*-
"""
Class for IntegrationPluginBase and Mixin Base
"""
import logging
import os
import inspect
from datetime import datetime
import pathlib
from django.urls.base import reverse
from django.conf import settings
from django.utils.translation import gettext_lazy as _
import plugin.plugin as plugin_base
from plugin.helpers import get_git_log, GitStatus
logger = logging.getLogger("inventree")
class MixinBase:
"""
Base set of mixin functions and mechanisms
"""
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
"""
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
"""
# get human name
human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
# register
self._mixinreg[key] = {
'key': key,
'human_name': human_name,
}
@property
def registered_mixins(self, with_base: bool = False):
"""
Get all registered mixins for the plugin
"""
mixins = getattr(self, '_mixinreg', None)
if mixins:
# filter out base
if not with_base and 'base' in mixins:
del mixins['base']
# only return dict
mixins = [a for a in mixins.values()]
return mixins
class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
"""
The IntegrationPluginBase class is used to integrate with 3rd party software
"""
AUTHOR = None
DESCRIPTION = None
PUBLISH_DATE = None
VERSION = None
WEBSITE = None
LICENSE = None
def __init__(self):
super().__init__()
self.add_mixin('base')
self.def_path = inspect.getfile(self.__class__)
self.path = os.path.dirname(self.def_path)
self.define_package()
@property
def _is_package(self):
"""
Is the plugin delivered as a package
"""
return getattr(self, 'is_package', False)
@property
def is_sample(self):
"""
Is this plugin part of the samples?
"""
path = str(self.package_path)
return path.startswith('plugin/samples/')
# region properties
@property
def slug(self):
"""
Slug of plugin
"""
return self.plugin_slug()
@property
def name(self):
"""
Name of plugin
"""
return self.plugin_name()
@property
def human_name(self):
"""
Human readable name of plugin
"""
return self.plugin_title()
@property
def description(self):
"""
Description of plugin
"""
description = getattr(self, 'DESCRIPTION', None)
if not description:
description = self.plugin_name()
return description
@property
def author(self):
"""
Author of plugin - either from plugin settings or git
"""
author = getattr(self, 'AUTHOR', None)
if not author:
author = self.package.get('author')
if not author:
author = _('No author found') # pragma: no cover
return author
@property
def pub_date(self):
"""
Publishing date of plugin - either from plugin settings or git
"""
pub_date = getattr(self, 'PUBLISH_DATE', None)
if not pub_date:
pub_date = self.package.get('date')
else:
pub_date = datetime.fromisoformat(str(pub_date))
if not pub_date:
pub_date = _('No date found') # pragma: no cover
return pub_date
@property
def version(self):
"""
Version of plugin
"""
version = getattr(self, 'VERSION', None)
return version
@property
def website(self):
"""
Website of plugin - if set else None
"""
website = getattr(self, 'WEBSITE', None)
return website
@property
def license(self):
"""
License of plugin
"""
lic = getattr(self, 'LICENSE', None)
return lic
# endregion
@property
def package_path(self):
"""
Path to the plugin
"""
if self._is_package:
return self.__module__ # pragma: no cover
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
@property
def settings_url(self):
"""
URL to the settings panel for this plugin
"""
return f'{reverse("settings")}#select-plugin-{self.slug}'
# region mixins
def mixin(self, key):
"""
Check if mixin is registered
"""
return key in self._mixins
def mixin_enabled(self, key):
"""
Check if mixin is registered, enabled and ready
"""
if self.mixin(key):
fnc_name = self._mixins.get(key)
# Allow for simple case where the mixin is "always" ready
if fnc_name is True:
return True
return getattr(self, fnc_name, True)
return False
# endregion
# region package info
def _get_package_commit(self):
"""
Get last git commit for the plugin
"""
return get_git_log(self.def_path)
def _get_package_metadata(self):
"""
Get package metadata for plugin
"""
return {} # pragma: no cover # TODO add usage for package metadata
def define_package(self):
"""
Add package info of the plugin into plugins context
"""
package = self._get_package_metadata() if self._is_package else self._get_package_commit()
# process date
if package.get('date'):
package['date'] = datetime.fromisoformat(package.get('date'))
# process sign state
sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
if sign_state.status == 0:
self.sign_color = 'success' # pragma: no cover
elif sign_state.status == 1:
self.sign_color = 'warning'
else:
self.sign_color = 'danger' # pragma: no cover
# set variables
self.package = package
self.sign_state = sign_state
# endregion

View File

@ -2,12 +2,14 @@
Utility class to enable simpler imports
"""
from ..builtin.integration.mixins import APICallMixin, AppMixin, LabelPrintingMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin, PanelMixin
from ..base.integration.mixins import APICallMixin, AppMixin, SettingsMixin, ScheduleMixin, UrlsMixin, NavigationMixin, PanelMixin
from common.notifications import SingleNotificationMethod, BulkNotificationMethod
from ..builtin.action.mixins import ActionMixin
from ..builtin.barcodes.mixins import BarcodeMixin
from ..base.action.mixins import ActionMixin
from ..base.barcodes.mixins import BarcodeMixin
from ..base.event.mixins import EventMixin
from ..base.label.mixins import LabelPrintingMixin
__all__ = [
'APICallMixin',

View File

@ -4,14 +4,16 @@ Plugin model definitions
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import warnings
from django.utils.translation import gettext_lazy as _
from django.db import models
from django.contrib.auth.models import User
from django.conf import settings
import common.models
from plugin import InvenTreePluginBase, registry
from plugin import InvenTreePlugin, registry
class PluginConfig(models.Model):
@ -59,7 +61,7 @@ class PluginConfig(models.Model):
try:
return self.plugin._mixinreg
except (AttributeError, ValueError):
except (AttributeError, ValueError): # pragma: no cover
return {}
# functions
@ -97,12 +99,14 @@ class PluginConfig(models.Model):
if not reload:
if (self.active is False and self.__org_active is True) or \
(self.active is True and self.__org_active is False):
if settings.PLUGIN_TESTING:
warnings.warn('A reload was triggered')
registry.reload_plugins()
return ret
class PluginSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting):
class PluginSetting(common.models.BaseInvenTreeSetting):
"""
This model represents settings for individual plugins
"""
@ -112,7 +116,13 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B
('plugin', 'key'),
]
REFERENCE_NAME = 'plugin'
plugin = models.ForeignKey(
PluginConfig,
related_name='settings',
null=False,
verbose_name=_('Plugin'),
on_delete=models.CASCADE,
)
@classmethod
def get_setting_definition(cls, key, **kwargs):
@ -135,23 +145,25 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B
if plugin:
if issubclass(plugin.__class__, InvenTreePluginBase):
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
kwargs['settings'] = registry.mixins_settings.get(plugin.key, {})
return super().get_setting_definition(key, **kwargs)
plugin = models.ForeignKey(
PluginConfig,
related_name='settings',
null=False,
verbose_name=_('Plugin'),
on_delete=models.CASCADE,
)
def get_kwargs(self):
"""
Explicit kwargs required to uniquely identify a particular setting object,
in addition to the 'key' parameter
"""
return {
'plugin': self.plugin,
}
class NotificationUserSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting):
class NotificationUserSetting(common.models.BaseInvenTreeSetting):
"""
This model represents notification settings for a user
"""
@ -161,8 +173,6 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo
('method', 'user', 'key'),
]
REFERENCE_NAME = 'method'
@classmethod
def get_setting_definition(cls, key, **kwargs):
from common.notifications import storage
@ -171,6 +181,17 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo
return super().get_setting_definition(key, **kwargs)
def get_kwargs(self):
"""
Explicit kwargs required to uniquely identify a particular setting object,
in addition to the 'key' parameter
"""
return {
'method': self.method,
'user': self.user,
}
method = models.CharField(
max_length=255,
verbose_name=_('Method'),

View File

@ -2,33 +2,71 @@
"""
Base Class for InvenTree plugins
"""
import logging
import os
import inspect
from datetime import datetime
import pathlib
import warnings
from django.conf import settings
from django.db.utils import OperationalError, ProgrammingError
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django.urls.base import reverse
from plugin.helpers import get_git_log, GitStatus
class InvenTreePluginBase():
"""
Base class for a plugin
DO NOT USE THIS DIRECTLY, USE plugin.IntegrationPluginBase
"""
logger = logging.getLogger("inventree")
def __init__(self):
pass
class MetaBase:
"""Base class for a plugins metadata"""
# Override the plugin name for each concrete plugin instance
PLUGIN_NAME = ''
NAME = ''
SLUG = None
TITLE = None
PLUGIN_SLUG = None
def get_meta_value(self, key: str, old_key: str = None, __default=None):
"""Reference a meta item with a key
PLUGIN_TITLE = None
Args:
key (str): key for the value
old_key (str, optional): depreceated key - will throw warning
__default (optional): Value if nothing with key can be found. Defaults to None.
Returns:
Value referenced with key, old_key or __default if set and not value found
"""
value = getattr(self, key, None)
# The key was not used
if old_key and value is None:
value = getattr(self, old_key, None)
# Sound of a warning if old_key worked
if value:
warnings.warn(f'Usage of {old_key} was depreciated in 0.7.0 in favour of {key}', DeprecationWarning)
# Use __default if still nothing set
if (value is None) and __default:
return __default
return value
def plugin_name(self):
"""
Name of plugin
"""
return self.PLUGIN_NAME
return self.get_meta_value('NAME', 'PLUGIN_NAME')
@property
def name(self):
"""
Name of plugin
"""
return self.plugin_name()
def plugin_slug(self):
"""
@ -36,22 +74,35 @@ class InvenTreePluginBase():
If not set plugin name slugified
"""
slug = getattr(self, 'PLUGIN_SLUG', None)
if slug is None:
slug = self.get_meta_value('SLUG', 'PLUGIN_SLUG', None)
if not slug:
slug = self.plugin_name()
return slugify(slug.lower())
@property
def slug(self):
"""
Slug of plugin
"""
return self.plugin_slug()
def plugin_title(self):
"""
Title of plugin
"""
if self.PLUGIN_TITLE:
return self.PLUGIN_TITLE
else:
return self.plugin_name()
title = self.get_meta_value('TITLE', 'PLUGIN_TITLE', None)
if title:
return title
return self.plugin_name()
@property
def human_name(self):
"""
Human readable name of plugin
"""
return self.plugin_title()
def plugin_config(self):
"""
@ -83,11 +134,230 @@ class InvenTreePluginBase():
return False # pragma: no cover
# TODO @matmair remove after InvenTree 0.7.0 release
class InvenTreePlugin(InvenTreePluginBase):
class MixinBase:
"""
This is here for leagcy reasons and will be removed in the next major release
Base set of mixin functions and mechanisms
"""
def __init__(self): # pragma: no cover
warnings.warn("Using the InvenTreePlugin is depreceated", DeprecationWarning)
def __init__(self, *args, **kwargs) -> None:
self._mixinreg = {}
self._mixins = {}
super().__init__(*args, **kwargs)
def mixin(self, key):
"""
Check if mixin is registered
"""
return key in self._mixins
def mixin_enabled(self, key):
"""
Check if mixin is registered, enabled and ready
"""
if self.mixin(key):
fnc_name = self._mixins.get(key)
# Allow for simple case where the mixin is "always" ready
if fnc_name is True:
return True
return getattr(self, fnc_name, True)
return False
def add_mixin(self, key: str, fnc_enabled=True, cls=None):
"""
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
"""
# get human name
human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
# register
self._mixinreg[key] = {
'key': key,
'human_name': human_name,
}
@property
def registered_mixins(self, with_base: bool = False):
"""
Get all registered mixins for the plugin
"""
mixins = getattr(self, '_mixinreg', None)
if mixins:
# filter out base
if not with_base and 'base' in mixins:
del mixins['base']
# only return dict
mixins = [a for a in mixins.values()]
return mixins
class InvenTreePlugin(MixinBase, MetaBase):
"""
The InvenTreePlugin class is used to integrate with 3rd party software
DO NOT USE THIS DIRECTLY, USE plugin.InvenTreePlugin
"""
AUTHOR = None
DESCRIPTION = None
PUBLISH_DATE = None
VERSION = None
WEBSITE = None
LICENSE = None
def __init__(self):
super().__init__()
self.add_mixin('base')
self.def_path = inspect.getfile(self.__class__)
self.path = os.path.dirname(self.def_path)
self.define_package()
# region properties
@property
def description(self):
"""
Description of plugin
"""
description = getattr(self, 'DESCRIPTION', None)
if not description:
description = self.plugin_name()
return description
@property
def author(self):
"""
Author of plugin - either from plugin settings or git
"""
author = getattr(self, 'AUTHOR', None)
if not author:
author = self.package.get('author')
if not author:
author = _('No author found') # pragma: no cover
return author
@property
def pub_date(self):
"""
Publishing date of plugin - either from plugin settings or git
"""
pub_date = getattr(self, 'PUBLISH_DATE', None)
if not pub_date:
pub_date = self.package.get('date')
else:
pub_date = datetime.fromisoformat(str(pub_date))
if not pub_date:
pub_date = _('No date found') # pragma: no cover
return pub_date
@property
def version(self):
"""
Version of plugin
"""
version = getattr(self, 'VERSION', None)
return version
@property
def website(self):
"""
Website of plugin - if set else None
"""
website = getattr(self, 'WEBSITE', None)
return website
@property
def license(self):
"""
License of plugin
"""
lic = getattr(self, 'LICENSE', None)
return lic
# endregion
@property
def _is_package(self):
"""
Is the plugin delivered as a package
"""
return getattr(self, 'is_package', False)
@property
def is_sample(self):
"""
Is this plugin part of the samples?
"""
path = str(self.package_path)
return path.startswith('plugin/samples/')
@property
def package_path(self):
"""
Path to the plugin
"""
if self._is_package:
return self.__module__ # pragma: no cover
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
@property
def settings_url(self):
"""
URL to the settings panel for this plugin
"""
return f'{reverse("settings")}#select-plugin-{self.slug}'
# region package info
def _get_package_commit(self):
"""
Get last git commit for the plugin
"""
return get_git_log(self.def_path)
def _get_package_metadata(self):
"""
Get package metadata for plugin
"""
return {} # pragma: no cover # TODO add usage for package metadata
def define_package(self):
"""
Add package info of the plugin into plugins context
"""
package = self._get_package_metadata() if self._is_package else self._get_package_commit()
# process date
if package.get('date'):
package['date'] = datetime.fromisoformat(package.get('date'))
# process sign state
sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
if sign_state.status == 0:
self.sign_color = 'success' # pragma: no cover
elif sign_state.status == 1:
self.sign_color = 'warning'
else:
self.sign_color = 'danger' # pragma: no cover
# set variables
self.package = package
self.sign_state = sign_state
# endregion
class IntegrationPluginBase(InvenTreePlugin):
def __init__(self, *args, **kwargs):
"""Send warning about using this reference"""
# TODO remove in 0.8.0
warnings.warn("This import is deprecated - use InvenTreePlugin", DeprecationWarning)
super().__init__(*args, **kwargs)

View File

@ -12,7 +12,7 @@ import os
import subprocess
from typing import OrderedDict
from importlib import reload
from importlib import reload, metadata
from django.apps import apps
from django.conf import settings
@ -22,16 +22,10 @@ from django.urls import clear_url_caches
from django.contrib import admin
from django.utils.text import slugify
try:
from importlib import metadata
except: # pragma: no cover
import importlib_metadata as metadata
# TODO remove when python minimum is 3.8
from maintenance_mode.core import maintenance_mode_on
from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
from .integration import IntegrationPluginBase
from .plugin import InvenTreePlugin
from .helpers import handle_error, log_error, get_plugins, IntegrationPluginError
@ -57,7 +51,6 @@ class PluginsRegistry:
self.apps_loading = True # Marks if apps were reloaded yet
self.git_is_modern = True # Is a modern version of git available
# integration specific
self.installed_apps = [] # Holds all added plugin_paths
# mixins
@ -129,7 +122,7 @@ class PluginsRegistry:
log_error({error.path: error.message}, 'load')
blocked_plugin = error.path # we will not try to load this app again
# Initialize apps without any integration plugins
# Initialize apps without any plugins
self._clean_registry()
self._clean_installed_apps()
self._activate_plugins(force_reload=True)
@ -198,9 +191,7 @@ class PluginsRegistry:
logger.info('Finished reloading plugins')
def collect_plugins(self):
"""
Collect integration plugins from all possible ways of loading
"""
"""Collect plugins from all possible ways of loading"""
if not settings.PLUGINS_ENABLED:
# Plugins not enabled, do nothing
@ -210,7 +201,7 @@ class PluginsRegistry:
# Collect plugins from paths
for plugin in settings.PLUGIN_DIRS:
modules = get_plugins(importlib.import_module(plugin), IntegrationPluginBase)
modules = get_plugins(importlib.import_module(plugin), InvenTreePlugin)
if modules:
[self.plugin_modules.append(item) for item in modules]
@ -236,7 +227,7 @@ class PluginsRegistry:
if settings.PLUGIN_FILE_CHECKED:
logger.info('Plugin file was already checked')
return
return True
try:
output = str(subprocess.check_output(['pip', 'install', '-U', '-r', settings.PLUGIN_FILE], cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')
@ -248,6 +239,7 @@ class PluginsRegistry:
# do not run again
settings.PLUGIN_FILE_CHECKED = True
return 'first_run'
# endregion
@ -280,15 +272,15 @@ class PluginsRegistry:
logger.info('Starting plugin initialisation')
# Initialize integration plugins
# Initialize plugins
for plugin in self.plugin_modules:
# 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!!
plug_name = plugin.PLUGIN_NAME
plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
plug_name = plugin.NAME
plug_key = plugin.SLUG if getattr(plugin, 'SLUG', None) else plug_name
plug_key = slugify(plug_key) # keys are slugs!
try:
plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
@ -320,7 +312,7 @@ class PluginsRegistry:
# 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.
logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
logger.info(f'Loading plugin {plug_name}')
try:
plugin = plugin()
@ -328,7 +320,7 @@ class PluginsRegistry:
# log error and raise it -> disable plugin
handle_error(error, log_name='init')
logger.debug(f'Loaded integration plugin {plugin.PLUGIN_NAME}')
logger.debug(f'Loaded plugin {plug_name}')
plugin.is_package = was_packaged
@ -343,7 +335,7 @@ class PluginsRegistry:
def _activate_plugins(self, force_reload=False):
"""
Run integration functions for all plugins
Run activation functions for all plugins
:param force_reload: force reload base apps, defaults to False
:type force_reload: bool, optional
@ -352,22 +344,20 @@ class PluginsRegistry:
plugins = self.plugins.items()
logger.info(f'Found {len(plugins)} active plugins')
self.activate_integration_settings(plugins)
self.activate_integration_schedule(plugins)
self.activate_integration_app(plugins, force_reload=force_reload)
self.activate_plugin_settings(plugins)
self.activate_plugin_schedule(plugins)
self.activate_plugin_app(plugins, force_reload=force_reload)
def _deactivate_plugins(self):
"""
Run integration deactivation functions for all plugins
"""
"""Run deactivation functions for all plugins"""
self.deactivate_integration_app()
self.deactivate_integration_schedule()
self.deactivate_integration_settings()
self.deactivate_plugin_app()
self.deactivate_plugin_schedule()
self.deactivate_plugin_settings()
# endregion
# region mixin specific loading ...
def activate_integration_settings(self, plugins):
def activate_plugin_settings(self, plugins):
logger.info('Activating plugin settings')
@ -378,7 +368,7 @@ class PluginsRegistry:
plugin_setting = plugin.settings
self.mixins_settings[slug] = plugin_setting
def deactivate_integration_settings(self):
def deactivate_plugin_settings(self):
# collect all settings
plugin_settings = {}
@ -389,7 +379,7 @@ class PluginsRegistry:
# clear cache
self.mixins_settings = {}
def activate_integration_schedule(self, plugins):
def activate_plugin_schedule(self, plugins):
logger.info('Activating plugin tasks')
@ -433,14 +423,14 @@ class PluginsRegistry:
# Database might not yet be ready
logger.warning("activate_integration_schedule failed, database not ready")
def deactivate_integration_schedule(self):
def deactivate_plugin_schedule(self):
"""
Deactivate ScheduleMixin
currently nothing is done
"""
pass
def activate_integration_app(self, plugins, force_reload=False):
def activate_plugin_app(self, plugins, force_reload=False):
"""
Activate AppMixin plugins - add custom apps and reload
@ -522,13 +512,11 @@ class PluginsRegistry:
plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
except ValueError: # pragma: no cover
# plugin is shipped as package
plugin_path = plugin.PLUGIN_NAME
plugin_path = plugin.NAME
return plugin_path
def deactivate_integration_app(self):
"""
Deactivate integration app - some magic required
"""
def deactivate_plugin_app(self):
"""Deactivate AppMixin plugins - some magic required"""
# unregister models from admin
for plugin_path in self.installed_apps:

View File

@ -2,18 +2,18 @@
Sample plugin which responds to events
"""
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
from plugin.mixins import EventMixin
class EventPluginSample(EventMixin, IntegrationPluginBase):
class EventPluginSample(EventMixin, InvenTreePlugin):
"""
A sample plugin which provides supports for triggered events
"""
PLUGIN_NAME = "EventPlugin"
PLUGIN_SLUG = "event"
PLUGIN_TITLE = "Triggered Events"
NAME = "EventPlugin"
SLUG = "event"
TITLE = "Triggered Events"
def process_event(self, event, *args, **kwargs):
""" Custom event processing """

View File

@ -1,19 +1,19 @@
"""sample implementation for IntegrationPlugin"""
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
from plugin.mixins import UrlsMixin
class NoIntegrationPlugin(IntegrationPluginBase):
class NoIntegrationPlugin(InvenTreePlugin):
"""
An basic integration plugin
An basic plugin
"""
PLUGIN_NAME = "NoIntegrationPlugin"
NAME = "NoIntegrationPlugin"
class WrongIntegrationPlugin(UrlsMixin, IntegrationPluginBase):
class WrongIntegrationPlugin(UrlsMixin, InvenTreePlugin):
"""
An basic integration plugin
An basic wron plugin with urls
"""
PLUGIN_NAME = "WrongIntegrationPlugin"
NAME = "WrongIntegrationPlugin"

View File

@ -1,15 +1,15 @@
"""
Sample plugin for calling an external API
"""
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
from plugin.mixins import APICallMixin, SettingsMixin
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, IntegrationPluginBase):
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, InvenTreePlugin):
"""
A small api call sample
"""
PLUGIN_NAME = "Sample API Caller"
NAME = "Sample API Caller"
SETTINGS = {
'API_TOKEN': {

View File

@ -1,10 +1,10 @@
"""sample of a broken python file that will be ignored on import"""
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
class BrokenFileIntegrationPlugin(IntegrationPluginBase):
class BrokenFileIntegrationPlugin(InvenTreePlugin):
"""
An very broken integration plugin
An very broken plugin
"""

View File

@ -1,14 +1,14 @@
"""sample of a broken integration plugin"""
from plugin import IntegrationPluginBase
"""sample of a broken plugin"""
from plugin import InvenTreePlugin
class BrokenIntegrationPlugin(IntegrationPluginBase):
class BrokenIntegrationPlugin(InvenTreePlugin):
"""
An very broken integration plugin
An very broken plugin
"""
PLUGIN_NAME = 'Test'
PLUGIN_TITLE = 'Broken Plugin'
PLUGIN_SLUG = 'broken'
NAME = 'Test'
TITLE = 'Broken Plugin'
SLUG = 'broken'
def __init__(self):
super().__init__()

View File

@ -2,21 +2,21 @@
Sample plugin which renders custom panels on certain pages
"""
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
from plugin.mixins import PanelMixin, SettingsMixin
from part.views import PartDetail
from stock.views import StockLocationDetail
class CustomPanelSample(PanelMixin, SettingsMixin, IntegrationPluginBase):
class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
"""
A sample plugin which renders some custom panels.
"""
PLUGIN_NAME = "CustomPanelExample"
PLUGIN_SLUG = "panel"
PLUGIN_TITLE = "Custom Panel Example"
NAME = "CustomPanelExample"
SLUG = "panel"
TITLE = "Custom Panel Example"
DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface"
VERSION = "0.1"

View File

@ -2,7 +2,7 @@
Sample implementations for IntegrationPlugin
"""
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin
from django.http import HttpResponse
@ -10,14 +10,14 @@ from django.utils.translation import gettext_lazy as _
from django.urls import include, re_path
class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase):
class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, InvenTreePlugin):
"""
A full integration plugin example
A full plugin example
"""
PLUGIN_NAME = "SampleIntegrationPlugin"
PLUGIN_SLUG = "sample"
PLUGIN_TITLE = "Sample Plugin"
NAME = "SampleIntegrationPlugin"
SLUG = "sample"
TITLE = "Sample Plugin"
NAVIGATION_TAB_NAME = "Sample Nav"
NAVIGATION_TAB_ICON = 'fas fa-plus'
@ -65,6 +65,16 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
],
'default': 'A',
},
'SELECT_COMPANY': {
'name': 'Company',
'description': 'Select a company object from the database',
'model': 'company.company',
},
'SELECT_PART': {
'name': 'Part',
'description': 'Select a part object from the database',
'model': 'part.part',
},
}
NAVIGATION = [

View File

@ -2,7 +2,7 @@
Sample plugin which supports task scheduling
"""
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
from plugin.mixins import ScheduleMixin, SettingsMixin
@ -15,14 +15,14 @@ def print_world():
print("World") # pragma: no cover
class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase):
class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, InvenTreePlugin):
"""
A sample plugin which provides support for scheduled tasks
"""
PLUGIN_NAME = "ScheduledTasksPlugin"
PLUGIN_SLUG = "schedule"
PLUGIN_TITLE = "Scheduled Tasks"
NAME = "ScheduledTasksPlugin"
SLUG = "schedule"
TITLE = "Scheduled Tasks"
SCHEDULED_TASKS = {
'member': {

View File

@ -2,7 +2,7 @@
from django.test import TestCase
from plugin import registry, IntegrationPluginBase
from plugin import registry, InvenTreePlugin
from plugin.helpers import MixinImplementationError
from plugin.registry import call_function
from plugin.mixins import ScheduleMixin
@ -45,16 +45,20 @@ class ExampleScheduledTaskPluginTests(TestCase):
def test_calling(self):
"""check if a function can be called without errors"""
# Check with right parameters
self.assertEqual(call_function('schedule', 'member_func'), False)
# Check with wrong key
self.assertEqual(call_function('does_not_exsist', 'member_func'), None)
class ScheduledTaskPluginTests(TestCase):
""" Tests for ScheduledTaskPluginTests mixin base """
def test_init(self):
"""Check that all MixinImplementationErrors raise"""
class Base(ScheduleMixin, IntegrationPluginBase):
PLUGIN_NAME = 'APlugin'
class Base(ScheduleMixin, InvenTreePlugin):
NAME = 'APlugin'
class NoSchedules(Base):
"""Plugin without schedules"""

View File

@ -96,8 +96,10 @@ class PluginConfigInstallSerializer(serializers.Serializer):
install_name.append(f'{packagename}@{url}')
else:
install_name.append(url)
else:
else: # pragma: no cover
# using a custom package repositories
# This is only for pypa compliant directory services (all current are tested above)
# and not covered by tests.
install_name.append('-i')
install_name.append(url)
install_name.append(packagename)

View File

@ -1,19 +1,12 @@
"""
load templates for loaded plugins
"""
"""Load templates for loaded plugins"""
import logging
from pathlib import Path
from django import template
from django.template.loaders.filesystem import Loader as FilesystemLoader
from plugin import registry
logger = logging.getLogger('inventree')
class PluginTemplateLoader(FilesystemLoader):
"""
A custom template loader which allows loading of templates from installed plugins.
@ -38,25 +31,3 @@ class PluginTemplateLoader(FilesystemLoader):
template_dirs.append(new_path)
return tuple(template_dirs)
def render_template(plugin, template_file, context=None):
"""
Locate and render a template file, available in the global template context.
"""
try:
tmp = template.loader.get_template(template_file)
except template.TemplateDoesNotExist:
logger.error(f"Plugin {plugin.slug} could not locate template '{template_file}'")
return f"""
<div class='alert alert-block alert-danger'>
Template file <em>{template_file}</em> does not exist.
</div>
"""
# Render with the provided context
html = tmp.render(context)
return html

View File

@ -16,7 +16,7 @@ register = template.Library()
@register.simple_tag()
def plugin_list(*args, **kwargs):
"""
List of all installed integration plugins
List of all installed plugins
"""
return registry.plugins
@ -24,7 +24,7 @@ def plugin_list(*args, **kwargs):
@register.simple_tag()
def inactive_plugin_list(*args, **kwargs):
"""
List of all inactive integration plugins
List of all inactive plugins
"""
return registry.plugins_inactive

View File

@ -45,6 +45,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
}, expected_code=201).data
self.assertEqual(data['success'], True)
# valid - github url and packagename
data = self.post(url, {
'confirm': True,
'url': self.PKG_URL,
'packagename': 'minimal',
}, expected_code=201).data
self.assertEqual(data['success'], True)
# invalid tries
# no input
self.post(url, {}, expected_code=400)
@ -124,3 +132,30 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
'_save': 'Save',
}, follow=True)
self.assertEqual(response.status_code, 200)
def test_model(self):
"""
Test the PluginConfig model
"""
from plugin.models import PluginConfig
from plugin import registry
fixtures = PluginConfig.objects.all()
# check if plugins were registered
if not fixtures:
registry.reload_plugins()
fixtures = PluginConfig.objects.all()
# check mixin registry
plg = fixtures.first()
mixin_dict = plg.mixins()
self.assertIn('base', mixin_dict)
self.assertDictContainsSubset({'base': {'key': 'base', 'human_name': 'base'}}, mixin_dict)
# check reload on save
with self.assertWarns(Warning) as cm:
plg_inactive = fixtures.filter(active=False).first()
plg_inactive.active = True
plg_inactive.save()
self.assertEqual(cm.warning.args[0], 'A reload was triggered')

View File

@ -0,0 +1,23 @@
"""Unit tests for helpers.py"""
from django.test import TestCase
from .helpers import render_template
class HelperTests(TestCase):
"""Tests for helpers"""
def test_render_template(self):
"""Check if render_template helper works"""
class ErrorSource:
slug = 'sampleplg'
# working sample
response = render_template(ErrorSource(), 'sample/sample.html', {'abc': 123})
self.assertEqual(response, '<h1>123</h1>')
# Wrong sample
response = render_template(ErrorSource(), 'sample/wrongsample.html', {'abc': 123})
self.assertTrue('lert alert-block alert-danger' in response)
self.assertTrue('Template file <em>sample/wrongsample.html</em>' in response)

View File

@ -2,38 +2,14 @@
Unit tests for plugins
"""
from datetime import datetime
from django.test import TestCase
from plugin.samples.integration.sample import SampleIntegrationPlugin
from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
import plugin.templatetags.plugin_extras as plugin_tags
from plugin import registry, InvenTreePluginBase
class InvenTreePluginTests(TestCase):
""" Tests for InvenTreePlugin """
def setUp(self):
self.plugin = InvenTreePluginBase()
class NamedPlugin(InvenTreePluginBase):
"""a named plugin"""
PLUGIN_NAME = 'abc123'
self.named_plugin = NamedPlugin()
def test_basic_plugin_init(self):
"""check if a basic plugin intis"""
self.assertEqual(self.plugin.PLUGIN_NAME, '')
self.assertEqual(self.plugin.plugin_name(), '')
def test_basic_plugin_name(self):
"""check if the name of a basic plugin can be set"""
self.assertEqual(self.named_plugin.PLUGIN_NAME, 'abc123')
self.assertEqual(self.named_plugin.plugin_name(), 'abc123')
def test_basic_is_active(self):
"""check if a basic plugin is active"""
self.assertEqual(self.plugin.is_active(), False)
from plugin import registry, InvenTreePlugin, IntegrationPluginBase
class PluginTagTests(TestCase):
@ -79,3 +55,118 @@ class PluginTagTests(TestCase):
def test_tag_plugin_errors(self):
"""test that all errors are listed"""
self.assertEqual(plugin_tags.plugin_errors(), registry.errors)
class InvenTreePluginTests(TestCase):
""" Tests for InvenTreePlugin """
def setUp(self):
self.plugin = InvenTreePlugin()
class NamedPlugin(InvenTreePlugin):
"""a named plugin"""
NAME = 'abc123'
self.named_plugin = NamedPlugin()
class SimpleInvenTreePlugin(InvenTreePlugin):
NAME = 'SimplePlugin'
self.plugin_simple = SimpleInvenTreePlugin()
class OldInvenTreePlugin(InvenTreePlugin):
PLUGIN_SLUG = 'old'
self.plugin_old = OldInvenTreePlugin()
class NameInvenTreePlugin(InvenTreePlugin):
NAME = 'Aplugin'
SLUG = 'a'
TITLE = 'a titel'
PUBLISH_DATE = "1111-11-11"
AUTHOR = 'AA BB'
DESCRIPTION = 'A description'
VERSION = '1.2.3a'
WEBSITE = 'http://aa.bb/cc'
LICENSE = 'MIT'
self.plugin_name = NameInvenTreePlugin()
self.plugin_sample = SampleIntegrationPlugin()
def test_basic_plugin_init(self):
"""check if a basic plugin intis"""
self.assertEqual(self.plugin.NAME, '')
self.assertEqual(self.plugin.plugin_name(), '')
def test_basic_plugin_name(self):
"""check if the name of a basic plugin can be set"""
self.assertEqual(self.named_plugin.NAME, 'abc123')
self.assertEqual(self.named_plugin.plugin_name(), 'abc123')
def test_basic_is_active(self):
"""check if a basic plugin is active"""
self.assertEqual(self.plugin.is_active(), False)
def test_action_name(self):
"""check the name definition possibilities"""
# plugin_name
self.assertEqual(self.plugin.plugin_name(), '')
self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin')
self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin')
# is_sampe
self.assertEqual(self.plugin.is_sample, False)
self.assertEqual(self.plugin_sample.is_sample, True)
# slug
self.assertEqual(self.plugin.slug, '')
self.assertEqual(self.plugin_simple.slug, 'simpleplugin')
self.assertEqual(self.plugin_name.slug, 'a')
# human_name
self.assertEqual(self.plugin.human_name, '')
self.assertEqual(self.plugin_simple.human_name, 'SimplePlugin')
self.assertEqual(self.plugin_name.human_name, 'a titel')
# description
self.assertEqual(self.plugin.description, '')
self.assertEqual(self.plugin_simple.description, 'SimplePlugin')
self.assertEqual(self.plugin_name.description, 'A description')
# author
self.assertEqual(self.plugin_name.author, 'AA BB')
# pub_date
self.assertEqual(self.plugin_name.pub_date, datetime(1111, 11, 11, 0, 0))
# version
self.assertEqual(self.plugin.version, None)
self.assertEqual(self.plugin_simple.version, None)
self.assertEqual(self.plugin_name.version, '1.2.3a')
# website
self.assertEqual(self.plugin.website, None)
self.assertEqual(self.plugin_simple.website, None)
self.assertEqual(self.plugin_name.website, 'http://aa.bb/cc')
# license
self.assertEqual(self.plugin.license, None)
self.assertEqual(self.plugin_simple.license, None)
self.assertEqual(self.plugin_name.license, 'MIT')
def test_depreciation(self):
"""Check if depreciations raise as expected"""
# check deprecation warning is firing
with self.assertWarns(DeprecationWarning):
self.assertEqual(self.plugin_old.slug, 'old')
# check default value is used
self.assertEqual(self.plugin_old.get_meta_value('ABC', 'ABCD', '123'), '123')
# check usage of the old class fires
class OldPlugin(IntegrationPluginBase):
pass
with self.assertWarns(DeprecationWarning):
plg = OldPlugin()
self.assertIsInstance(plg, InvenTreePlugin)

View File

@ -1,2 +1,5 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals # pragma: no cover
"""
Directory for custom plugin development
Please read the docs for more information https://inventree.readthedocs.io/en/latest/extend/plugins/#local-directory
"""

View File

@ -33,7 +33,7 @@ from company.serializers import CompanySerializer, SupplierPartSerializer
from InvenTree.helpers import str2bool, isNull, extract_serial_numbers
from InvenTree.helpers import DownloadFile
from InvenTree.api import AttachmentMixin
from InvenTree.api import AttachmentMixin, APIDownloadMixin
from InvenTree.filters import InvenTreeOrderingFilter
from order.models import PurchaseOrder
@ -505,7 +505,7 @@ class StockFilter(rest_filters.FilterSet):
updated_after = rest_filters.DateFilter(label='Updated after', field_name='updated', lookup_expr='gte')
class StockList(generics.ListCreateAPIView):
class StockList(APIDownloadMixin, generics.ListCreateAPIView):
""" API endpoint for list view of Stock objects
- GET: Return a list of all StockItem objects (with optional query filters)
@ -646,6 +646,22 @@ class StockList(generics.ListCreateAPIView):
return Response(response_data, status=status.HTTP_201_CREATED, headers=self.get_success_headers(serializer.data))
def download_queryset(self, queryset, export_format):
"""
Download this queryset as a file.
Uses the APIDownloadMixin mixin class
"""
dataset = StockItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = 'InvenTree_StockItems_{date}.{fmt}'.format(
date=datetime.now().strftime("%d-%b-%Y"),
fmt=export_format
)
return DownloadFile(filedata, filename)
def list(self, request, *args, **kwargs):
"""
Override the 'list' method, as the StockLocation objects
@ -658,25 +674,6 @@ class StockList(generics.ListCreateAPIView):
params = request.query_params
# Check if we wish to export the queried data to a file.
# If so, skip pagination!
export_format = params.get('export', None)
if export_format:
export_format = str(export_format).strip().lower()
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
dataset = StockItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
date=datetime.now().strftime("%d-%b-%Y"),
fmt=export_format
)
return DownloadFile(filedata, filename)
page = self.paginate_queryset(queryset)
if page is not None:

View File

@ -556,7 +556,14 @@ class StockItem(MPTTModel):
# If the item points to a build, check that the Part references match
if self.build:
if not self.part == self.build.part:
if self.part == self.build.part:
# Part references match exactly
pass
elif self.part in self.build.part.get_conversion_options():
# Part reference is one of the valid conversion options for the build output
pass
else:
raise ValidationError({
'build': _("Build reference does not point to the same part object")
})

View File

@ -71,9 +71,24 @@ function editSetting(key, options={}) {
help_text: response.description,
type: response.type,
choices: response.choices,
value: response.value,
}
};
// Foreign key lookup available!
if (response.type == 'related field') {
if (response.model_name && response.api_url) {
fields.value.type = 'related field';
fields.value.model = response.model_name.split('.').at(-1);
fields.value.api_url = response.api_url;
} else {
// Unknown / unsupported model type, default to 'text' field
fields.value.type = 'text';
console.warn(`Unsupported model type: '${response.model_name}' for setting '${response.key}'`);
}
}
constructChangeForm(fields, {
url: url,
method: 'PATCH',

View File

@ -2355,7 +2355,7 @@ function loadBuildTable(table, options) {
var filterTarget = options.filterTarget || null;
setupFilterList('build', table, filterTarget);
setupFilterList('build', table, filterTarget, {download: true});
$(table).inventreeTable({
method: 'get',

View File

@ -1394,7 +1394,9 @@ function loadPurchaseOrderTable(table, options) {
filters[key] = options.params[key];
}
setupFilterList('purchaseorder', $(table));
var target = '#filter-list-purchaseorder';
setupFilterList('purchaseorder', $(table), target, {download: true});
$(table).inventreeTable({
url: '{% url "api-po-list" %}',
@ -2091,7 +2093,9 @@ function loadSalesOrderTable(table, options) {
options.url = options.url || '{% url "api-so-list" %}';
setupFilterList('salesorder', $(table));
var target = '#filter-list-salesorder';
setupFilterList('salesorder', $(table), target, {download: true});
$(table).inventreeTable({
url: options.url,

View File

@ -0,0 +1 @@
<h1>{{abc}}</h1>

View File

@ -648,36 +648,6 @@ class Owner(models.Model):
owner_type=content_type_id)
except Owner.DoesNotExist:
pass
else:
# Check whether user_or_group is a Group instance
try:
group = Group.objects.get(pk=user_or_group.id)
except Group.DoesNotExist:
group = None
if group:
try:
owner = Owner.objects.get(owner_id=user_or_group.id,
owner_type=content_type_id_list[0])
except Owner.DoesNotExist:
pass
return owner
# Check whether user_or_group is a User instance
try:
user = user_model.objects.get(pk=user_or_group.id)
except user_model.DoesNotExist:
user = None
if user:
try:
owner = Owner.objects.get(owner_id=user_or_group.id,
owner_type=content_type_id_list[1])
except Owner.DoesNotExist:
pass
return owner
return owner