mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'fix-boolean' of https://github.com/matmair/InvenTree into fix-boolean
This commit is contained in:
commit
136d642703
19
.github/workflows/qc_checks.yaml
vendored
19
.github/workflows/qc_checks.yaml
vendored
@ -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
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 """
|
||||
|
66
InvenTree/InvenTree/test_middleware.py
Normal file
66
InvenTree/InvenTree/test_middleware.py
Normal 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)
|
@ -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())
|
||||
|
@ -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)),
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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"""
|
||||
...
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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)
|
@ -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))
|
||||
)
|
||||
|
0
InvenTree/plugin/base/__init__.py
Normal file
0
InvenTree/plugin/base/__init__.py
Normal file
0
InvenTree/plugin/base/action/__init__.py
Normal file
0
InvenTree/plugin/base/action/__init__.py
Normal file
45
InvenTree/plugin/base/action/api.py
Normal file
45
InvenTree/plugin/base/action/api.py
Normal 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,
|
||||
})
|
@ -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
|
@ -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'}
|
||||
)
|
0
InvenTree/plugin/base/barcodes/__init__.py
Normal file
0
InvenTree/plugin/base/barcodes/__init__.py
Normal 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'
|
0
InvenTree/plugin/base/event/__init__.py
Normal file
0
InvenTree/plugin/base/event/__init__.py
Normal file
192
InvenTree/plugin/base/event/events.py
Normal file
192
InvenTree/plugin/base/event/events.py
Normal 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__,
|
||||
)
|
29
InvenTree/plugin/base/event/mixins.py
Normal file
29
InvenTree/plugin/base/event/mixins.py
Normal 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__)
|
0
InvenTree/plugin/base/integration/__init__.py
Normal file
0
InvenTree/plugin/base/integration/__init__.py
Normal 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
|
@ -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()
|
0
InvenTree/plugin/base/label/__init__.py
Normal file
0
InvenTree/plugin/base/label/__init__.py
Normal file
53
InvenTree/plugin/base/label/label.py
Normal file
53
InvenTree/plugin/base/label/label.py
Normal 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]
|
||||
)
|
39
InvenTree/plugin/base/label/mixins.py
Normal file
39
InvenTree/plugin/base/label/mixins.py
Normal 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')
|
@ -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):
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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,
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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',
|
||||
|
@ -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'),
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
0
InvenTree/plugin/samples/event/__init__.py
Normal file
0
InvenTree/plugin/samples/event/__init__.py
Normal 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 """
|
@ -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"
|
||||
|
@ -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': {
|
||||
|
@ -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
|
||||
"""
|
||||
|
||||
|
||||
|
@ -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__()
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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 = [
|
||||
|
@ -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': {
|
||||
|
@ -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"""
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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')
|
||||
|
23
InvenTree/plugin/test_helpers.py
Normal file
23
InvenTree/plugin/test_helpers.py
Normal 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)
|
@ -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)
|
||||
|
@ -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
|
||||
"""
|
||||
|
@ -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:
|
||||
|
@ -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")
|
||||
})
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
1
InvenTree/templates/sample/sample.html
Normal file
1
InvenTree/templates/sample/sample.html
Normal file
@ -0,0 +1 @@
|
||||
<h1>{{abc}}</h1>
|
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user