mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into not-working-tests
This commit is contained in:
commit
bfd8b1e3b5
2
.github/workflows/qc_checks.yaml
vendored
2
.github/workflows/qc_checks.yaml
vendored
@ -12,7 +12,7 @@ on:
|
|||||||
- l10*
|
- l10*
|
||||||
|
|
||||||
env:
|
env:
|
||||||
python_version: 3.8
|
python_version: 3.9
|
||||||
node_version: 16
|
node_version: 16
|
||||||
|
|
||||||
server_start_sleep: 60
|
server_start_sleep: 60
|
||||||
|
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
|||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
stale-issue-message: 'This issue seems stale. Please react to show this is still important.'
|
stale-issue-message: 'This issue seems stale. Please react to show this is still important.'
|
||||||
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
|
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
|
||||||
stale-issue-label: 'no-activity'
|
stale-issue-label: 'inactive'
|
||||||
stale-pr-label: 'no-activity'
|
stale-pr-label: 'inactive'
|
||||||
start-date: '2022-01-01'
|
start-date: '2022-01-01'
|
||||||
exempt-all-milestones: true
|
exempt-all-milestones: true
|
||||||
|
4
.github/workflows/translations.yml
vendored
4
.github/workflows/translations.yml
vendored
@ -21,10 +21,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Set up Python 3.7
|
- name: Set up Python 3.9
|
||||||
uses: actions/setup-python@v1
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: 3.7
|
python-version: 3.9
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
|
9
.github/workflows/welcome.yml
vendored
9
.github/workflows/welcome.yml
vendored
@ -16,5 +16,10 @@ jobs:
|
|||||||
- uses: actions/first-interaction@v1
|
- uses: actions/first-interaction@v1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
issue-message: 'Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help.\nIf you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/).'
|
issue-message: |
|
||||||
pr-message: 'This is your first PR, welcome!\nPlease check [Contributing](https://github.com/inventree/InvenTree/blob/master/CONTRIBUTING.md) to make sure your submission fits our general code-style and workflow.\nMake sure to document why this PR is needed and to link connected issues so we can review it faster.'
|
Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help.
|
||||||
|
If you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/).
|
||||||
|
pr-message: |
|
||||||
|
This is your first PR, welcome!
|
||||||
|
Please check [Contributing](https://github.com/inventree/InvenTree/blob/master/CONTRIBUTING.md) to make sure your submission fits our general code-style and workflow.
|
||||||
|
Make sure to document why this PR is needed and to link connected issues so we can review it faster.
|
||||||
|
@ -13,15 +13,11 @@ from django_filters.rest_framework import DjangoFilterBackend
|
|||||||
from rest_framework import filters
|
from rest_framework import filters
|
||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
|
|
||||||
from .views import AjaxView
|
from .views import AjaxView
|
||||||
from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
|
from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
|
||||||
from .status import is_worker_running
|
from .status import is_worker_running
|
||||||
|
|
||||||
from plugin import registry
|
|
||||||
|
|
||||||
|
|
||||||
class InfoView(AjaxView):
|
class InfoView(AjaxView):
|
||||||
""" Simple JSON endpoint for InvenTree information.
|
""" Simple JSON endpoint for InvenTree information.
|
||||||
@ -119,40 +115,3 @@ class AttachmentMixin:
|
|||||||
attachment = serializer.save()
|
attachment = serializer.save()
|
||||||
attachment.user = self.request.user
|
attachment.user = self.request.user
|
||||||
attachment.save()
|
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,
|
|
||||||
})
|
|
||||||
|
@ -197,8 +197,6 @@ class InvenTreeConfig(AppConfig):
|
|||||||
logger.info(f'User {str(new_user)} was created!')
|
logger.info(f'User {str(new_user)} was created!')
|
||||||
except IntegrityError as _e:
|
except IntegrityError as _e:
|
||||||
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_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
|
# do not try again
|
||||||
settings.USER_ADDED = True
|
settings.USER_ADDED = True
|
||||||
|
@ -96,6 +96,12 @@ class HTMLAPITests(TestCase):
|
|||||||
response = self.client.get(url, HTTP_ACCEPT='text/html')
|
response = self.client.get(url, HTTP_ACCEPT='text/html')
|
||||||
self.assertEqual(response.status_code, 200)
|
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):
|
class APITests(InvenTreeAPITestCase):
|
||||||
""" Tests for the InvenTree API """
|
""" Tests for the InvenTree API """
|
||||||
|
@ -32,9 +32,6 @@ class MiddlewareTests(TestCase):
|
|||||||
# logout
|
# logout
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
|
|
||||||
# check that static files go through
|
|
||||||
self.check_path('/static/css/inventree.css')
|
|
||||||
|
|
||||||
# check that account things go through
|
# check that account things go through
|
||||||
self.check_path(reverse('account_login'))
|
self.check_path(reverse('account_login'))
|
||||||
|
|
||||||
@ -62,5 +59,8 @@ class MiddlewareTests(TestCase):
|
|||||||
# request with token
|
# request with token
|
||||||
self.check_path(reverse('settings.js'), HTTP_Authorization=f'Token {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
|
# should still fail without token
|
||||||
self.check_path(reverse('settings.js'), 401)
|
self.check_path(reverse('settings.js'), 401)
|
||||||
|
@ -451,6 +451,11 @@ class TestSettings(TestCase):
|
|||||||
self.user_mdl = get_user_model()
|
self.user_mdl = get_user_model()
|
||||||
self.env = EnvironmentVarGuard()
|
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):
|
def run_reload(self):
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
|
|
||||||
@ -467,23 +472,49 @@ class TestSettings(TestCase):
|
|||||||
|
|
||||||
# nothing set
|
# nothing set
|
||||||
self.run_reload()
|
self.run_reload()
|
||||||
self.assertEqual(user_count(), 0)
|
self.assertEqual(user_count(), 1)
|
||||||
|
|
||||||
# not enough set
|
# not enough set
|
||||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
||||||
self.run_reload()
|
self.run_reload()
|
||||||
self.assertEqual(user_count(), 0)
|
self.assertEqual(user_count(), 1)
|
||||||
|
|
||||||
# enough set
|
# enough set
|
||||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
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_EMAIL', 'info@example.com') # set email
|
||||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password
|
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password
|
||||||
self.run_reload()
|
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
|
# make sure to clean up
|
||||||
settings.TESTING_ENV = False
|
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):
|
def test_helpers_cfg_file(self):
|
||||||
# normal run - not configured
|
# normal run - not configured
|
||||||
self.assertIn('InvenTree/InvenTree/config.yaml', config.get_config_file())
|
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 label.api import label_api_urls
|
||||||
from report.api import report_api_urls
|
from report.api import report_api_urls
|
||||||
from plugin.api import plugin_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 import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
@ -45,20 +45,11 @@ from .views import DynamicJsView
|
|||||||
from .views import NotificationsView
|
from .views import NotificationsView
|
||||||
|
|
||||||
from .api import InfoView, NotFoundView
|
from .api import InfoView, NotFoundView
|
||||||
from .api import ActionPluginView
|
|
||||||
|
|
||||||
from users.api import user_urls
|
|
||||||
|
|
||||||
admin.site.site_header = "InvenTree Admin"
|
admin.site.site_header = "InvenTree Admin"
|
||||||
|
|
||||||
apipatterns = []
|
|
||||||
|
|
||||||
if settings.PLUGINS_ENABLED:
|
apipatterns = [
|
||||||
apipatterns.append(
|
|
||||||
re_path(r'^plugin/', include(plugin_api_urls))
|
|
||||||
)
|
|
||||||
|
|
||||||
apipatterns += [
|
|
||||||
re_path(r'^settings/', include(settings_api_urls)),
|
re_path(r'^settings/', include(settings_api_urls)),
|
||||||
re_path(r'^part/', include(part_api_urls)),
|
re_path(r'^part/', include(part_api_urls)),
|
||||||
re_path(r'^bom/', include(bom_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'^order/', include(order_api_urls)),
|
||||||
re_path(r'^label/', include(label_api_urls)),
|
re_path(r'^label/', include(label_api_urls)),
|
||||||
re_path(r'^report/', include(report_api_urls)),
|
re_path(r'^report/', include(report_api_urls)),
|
||||||
|
|
||||||
# User URLs
|
|
||||||
re_path(r'^user/', include(user_urls)),
|
re_path(r'^user/', include(user_urls)),
|
||||||
|
|
||||||
# Plugin endpoints
|
# Plugin endpoints
|
||||||
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
|
path('', include(plugin_api_urls)),
|
||||||
re_path(r'^barcode/', include(barcode_api_urls)),
|
|
||||||
|
|
||||||
# Webhook enpoint
|
# Webhook enpoint
|
||||||
path('', include(common_api_urls)),
|
path('', include(common_api_urls)),
|
||||||
|
@ -271,9 +271,9 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
plugin = kwargs.get('plugin', None)
|
plugin = kwargs.get('plugin', None)
|
||||||
|
|
||||||
if plugin is not 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()
|
plugin = plugin.plugin_config()
|
||||||
|
|
||||||
filters['plugin'] = plugin
|
filters['plugin'] = plugin
|
||||||
@ -375,9 +375,9 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
filters['user'] = user
|
filters['user'] = user
|
||||||
|
|
||||||
if plugin is not None:
|
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()
|
filters['plugin'] = plugin.plugin_config()
|
||||||
else:
|
else:
|
||||||
filters['plugin'] = plugin
|
filters['plugin'] = plugin
|
||||||
|
@ -108,7 +108,7 @@ class NotificationMethod:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if method globally enabled
|
# 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):
|
if plg_instance and not plg_instance.get_setting(self.GLOBAL_SETTING):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -10,7 +10,8 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
from InvenTree.helpers import str2bool
|
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, ColorTheme
|
from .models import InvenTreeSetting, InvenTreeUserSetting, WebhookEndpoint, WebhookMessage, NotificationEntry, ColorTheme
|
||||||
from .api import WebhookView
|
from .api import WebhookView
|
||||||
@ -477,15 +478,36 @@ class PluginSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.get(url, expected_code=200)
|
self.get(url, expected_code=200)
|
||||||
|
|
||||||
def test_invalid_plugin_slug(self):
|
def test_valid_plugin_slug(self):
|
||||||
"""Test that an invalid plugin slug returns a 404"""
|
"""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'})
|
url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'doesnotexist', 'key': 'doesnotmatter'})
|
||||||
|
|
||||||
response = self.get(url, expected_code=404)
|
response = self.get(url, expected_code=404)
|
||||||
|
|
||||||
self.assertIn("Plugin 'doesnotexist' not installed", str(response.data))
|
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):
|
def test_invalid_setting_key(self):
|
||||||
"""Test that an invalid setting key returns a 404"""
|
"""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 a background task to print the provided label
|
||||||
offload_task(
|
offload_task(
|
||||||
'plugin.events.print_label',
|
'plugin.base.label.label.print_label',
|
||||||
plugin.plugin_slug(),
|
plugin.plugin_slug(),
|
||||||
image,
|
image,
|
||||||
label_instance=label_instance,
|
label_instance=label_instance,
|
||||||
|
@ -7,13 +7,12 @@ import os
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.urls import reverse
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from InvenTree.helpers import validateFilterString
|
from InvenTree.helpers import validateFilterString
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
|
|
||||||
from .models import StockItemLabel, StockLocationLabel, PartLabel
|
from .models import StockItemLabel, StockLocationLabel
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
|
||||||
|
|
||||||
@ -86,13 +85,3 @@ class LabelTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
validateFilterString(bad_filter_string, model=StockItem)
|
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)
|
|
||||||
|
@ -3,17 +3,14 @@ Utility file to enable simper imports
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .registry import registry
|
from .registry import registry
|
||||||
from .plugin import InvenTreePluginBase
|
from .plugin import InvenTreePlugin, IntegrationPluginBase
|
||||||
from .integration import IntegrationPluginBase
|
|
||||||
from .action import ActionPlugin
|
|
||||||
|
|
||||||
from .helpers import MixinNotImplementedError, MixinImplementationError
|
from .helpers import MixinNotImplementedError, MixinImplementationError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'ActionPlugin',
|
|
||||||
'IntegrationPluginBase',
|
|
||||||
'InvenTreePluginBase',
|
|
||||||
'registry',
|
'registry',
|
||||||
|
|
||||||
|
'InvenTreePlugin',
|
||||||
|
IntegrationPluginBase,
|
||||||
'MixinNotImplementedError',
|
'MixinNotImplementedError',
|
||||||
'MixinImplementationError',
|
'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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
|
|
||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
@ -16,6 +17,8 @@ from rest_framework.response import Response
|
|||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
|
||||||
from common.api import GlobalSettingsPermissions
|
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
|
from plugin.models import PluginConfig, PluginSetting
|
||||||
import plugin.serializers as PluginSerializers
|
import plugin.serializers as PluginSerializers
|
||||||
from plugin.registry import registry
|
from plugin.registry import registry
|
||||||
@ -141,7 +144,8 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
|
|||||||
plugin = registry.get_plugin(plugin_slug)
|
plugin = registry.get_plugin(plugin_slug)
|
||||||
|
|
||||||
if plugin is None:
|
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', {})
|
settings = getattr(plugin, 'SETTINGS', {})
|
||||||
|
|
||||||
@ -157,6 +161,11 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
|
|||||||
|
|
||||||
|
|
||||||
plugin_api_urls = [
|
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
|
# Plugin settings URLs
|
||||||
re_path(r'^settings/', include([
|
re_path(r'^settings/', include([
|
||||||
@ -174,3 +183,8 @@ plugin_api_urls = [
|
|||||||
# Anything else
|
# Anything else
|
||||||
re_path(r'^.*$', PluginList.as_view(), name='api-plugin-list'),
|
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'
|
MIXIN_NAME = 'Actions'
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, user=None, data=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('action', True, __class__)
|
self.add_mixin('action', True, __class__)
|
||||||
|
self.init(user, data)
|
||||||
|
|
||||||
def action_name(self):
|
def action_name(self):
|
||||||
"""
|
"""
|
||||||
Action name for this plugin.
|
Action name for this plugin.
|
||||||
|
|
||||||
If the ACTION_NAME parameter is empty,
|
If the ACTION_NAME parameter is empty,
|
||||||
uses the PLUGIN_NAME instead.
|
uses the NAME instead.
|
||||||
"""
|
"""
|
||||||
if self.ACTION_NAME:
|
if self.ACTION_NAME:
|
||||||
return self.ACTION_NAME
|
return self.ACTION_NAME
|
@ -1,34 +1,38 @@
|
|||||||
""" Unit tests for action plugins """
|
""" Unit tests for action plugins """
|
||||||
|
|
||||||
from django.test import TestCase
|
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):
|
class ActionMixinTests(TestCase):
|
||||||
""" Tests for ActionPlugin """
|
""" Tests for ActionMixin """
|
||||||
ACTION_RETURN = 'a action was performed'
|
ACTION_RETURN = 'a action was performed'
|
||||||
|
|
||||||
def setUp(self):
|
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"""
|
"""a action plugin"""
|
||||||
ACTION_NAME = 'abc123'
|
ACTION_NAME = 'abc123'
|
||||||
|
|
||||||
def perform_action(self):
|
def perform_action(self):
|
||||||
return ActionPluginTests.ACTION_RETURN + 'action'
|
return ActionMixinTests.ACTION_RETURN + 'action'
|
||||||
|
|
||||||
def get_result(self):
|
def get_result(self):
|
||||||
return ActionPluginTests.ACTION_RETURN + 'result'
|
return ActionMixinTests.ACTION_RETURN + 'result'
|
||||||
|
|
||||||
def get_info(self):
|
def get_info(self):
|
||||||
return ActionPluginTests.ACTION_RETURN + 'info'
|
return ActionMixinTests.ACTION_RETURN + 'info'
|
||||||
|
|
||||||
self.action_plugin = TestActionPlugin('user')
|
self.action_plugin = TestActionPlugin('user')
|
||||||
|
|
||||||
class NameActionPlugin(ActionPlugin):
|
class NameActionPlugin(ActionMixin, InvenTreePlugin):
|
||||||
PLUGIN_NAME = 'Aplugin'
|
NAME = 'Aplugin'
|
||||||
|
|
||||||
self.action_name = NameActionPlugin('user')
|
self.action_name = NameActionPlugin('user')
|
||||||
|
|
||||||
@ -59,3 +63,32 @@ class ActionPluginTests(TestCase):
|
|||||||
"result": self.ACTION_RETURN + 'result',
|
"result": self.ACTION_RETURN + 'result',
|
||||||
"info": self.ACTION_RETURN + 'info',
|
"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 stock.serializers import StockItemSerializer
|
||||||
|
|
||||||
from plugin.builtin.barcodes.inventree_barcode import InvenTreeBarcodePlugin
|
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
|
from plugin import registry
|
||||||
|
|
||||||
|
|
||||||
@ -237,7 +237,7 @@ class BarcodeAssign(APIView):
|
|||||||
|
|
||||||
|
|
||||||
barcode_api_urls = [
|
barcode_api_urls = [
|
||||||
|
# Link a barcode to a part
|
||||||
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
|
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
|
||||||
|
|
||||||
# Catch-all performs barcode 'scan'
|
# 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
|
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.models import PluginConfig, PluginSetting
|
||||||
from plugin.template import render_template
|
|
||||||
from plugin.urls import PLUGIN_BASE
|
from plugin.urls import PLUGIN_BASE
|
||||||
|
|
||||||
|
|
||||||
@ -238,32 +237,6 @@ class ScheduleMixin:
|
|||||||
logger.warning("unregister_tasks failed, database not ready")
|
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:
|
class UrlsMixin:
|
||||||
"""
|
"""
|
||||||
Mixin that enables custom URLs for the plugin
|
Mixin that enables custom URLs for the plugin
|
||||||
@ -396,42 +369,6 @@ class AppMixin:
|
|||||||
return True
|
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:
|
class APICallMixin:
|
||||||
"""
|
"""
|
||||||
Mixin that enables easier API calls for a plugin
|
Mixin that enables easier API calls for a plugin
|
||||||
@ -447,15 +384,15 @@ class APICallMixin:
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
```
|
```
|
||||||
from plugin import IntegrationPluginBase
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import APICallMixin, SettingsMixin
|
from plugin.mixins import APICallMixin, SettingsMixin
|
||||||
|
|
||||||
|
|
||||||
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, IntegrationPluginBase):
|
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, InvenTreePlugin):
|
||||||
'''
|
'''
|
||||||
A small api call sample
|
A small api call sample
|
||||||
'''
|
'''
|
||||||
PLUGIN_NAME = "Sample API Caller"
|
NAME = "Sample API Caller"
|
||||||
|
|
||||||
SETTINGS = {
|
SETTINGS = {
|
||||||
'API_TOKEN': {
|
'API_TOKEN': {
|
||||||
@ -496,9 +433,9 @@ class APICallMixin:
|
|||||||
def has_api_call(self):
|
def has_api_call(self):
|
||||||
"""Is the mixin ready to call external APIs?"""
|
"""Is the mixin ready to call external APIs?"""
|
||||||
if not bool(self.API_URL_SETTING):
|
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):
|
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
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
@ -1,17 +1,14 @@
|
|||||||
""" Unit tests for integration plugins """
|
""" Unit tests for base mixins for plugins """
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from datetime import datetime
|
from plugin import InvenTreePlugin
|
||||||
|
|
||||||
from plugin import IntegrationPluginBase
|
|
||||||
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
|
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
|
||||||
from plugin.urls import PLUGIN_BASE
|
from plugin.urls import PLUGIN_BASE
|
||||||
|
from plugin.helpers import MixinNotImplementedError
|
||||||
from plugin.samples.integration.sample import SampleIntegrationPlugin
|
|
||||||
|
|
||||||
|
|
||||||
class BaseMixinDefinition:
|
class BaseMixinDefinition:
|
||||||
@ -30,11 +27,11 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
TEST_SETTINGS = {'SETTING1': {'default': '123', }}
|
TEST_SETTINGS = {'SETTING1': {'default': '123', }}
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
class SettingsCls(SettingsMixin, IntegrationPluginBase):
|
class SettingsCls(SettingsMixin, InvenTreePlugin):
|
||||||
SETTINGS = self.TEST_SETTINGS
|
SETTINGS = self.TEST_SETTINGS
|
||||||
self.mixin = SettingsCls()
|
self.mixin = SettingsCls()
|
||||||
|
|
||||||
class NoSettingsCls(SettingsMixin, IntegrationPluginBase):
|
class NoSettingsCls(SettingsMixin, InvenTreePlugin):
|
||||||
pass
|
pass
|
||||||
self.mixin_nothing = NoSettingsCls()
|
self.mixin_nothing = NoSettingsCls()
|
||||||
|
|
||||||
@ -65,13 +62,13 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
MIXIN_ENABLE_CHECK = 'has_urls'
|
MIXIN_ENABLE_CHECK = 'has_urls'
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
class UrlsCls(UrlsMixin, IntegrationPluginBase):
|
class UrlsCls(UrlsMixin, InvenTreePlugin):
|
||||||
def test():
|
def test():
|
||||||
return 'ccc'
|
return 'ccc'
|
||||||
URLS = [re_path('testpath', test, name='test'), ]
|
URLS = [re_path('testpath', test, name='test'), ]
|
||||||
self.mixin = UrlsCls()
|
self.mixin = UrlsCls()
|
||||||
|
|
||||||
class NoUrlsCls(UrlsMixin, IntegrationPluginBase):
|
class NoUrlsCls(UrlsMixin, InvenTreePlugin):
|
||||||
pass
|
pass
|
||||||
self.mixin_nothing = NoUrlsCls()
|
self.mixin_nothing = NoUrlsCls()
|
||||||
|
|
||||||
@ -104,7 +101,7 @@ class AppMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
MIXIN_ENABLE_CHECK = 'has_app'
|
MIXIN_ENABLE_CHECK = 'has_app'
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
class TestCls(AppMixin, IntegrationPluginBase):
|
class TestCls(AppMixin, InvenTreePlugin):
|
||||||
pass
|
pass
|
||||||
self.mixin = TestCls()
|
self.mixin = TestCls()
|
||||||
|
|
||||||
@ -119,30 +116,32 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
MIXIN_ENABLE_CHECK = 'has_naviation'
|
MIXIN_ENABLE_CHECK = 'has_naviation'
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
class NavigationCls(NavigationMixin, IntegrationPluginBase):
|
class NavigationCls(NavigationMixin, InvenTreePlugin):
|
||||||
NAVIGATION = [
|
NAVIGATION = [
|
||||||
{'name': 'aa', 'link': 'plugin:test:test_view'},
|
{'name': 'aa', 'link': 'plugin:test:test_view'},
|
||||||
]
|
]
|
||||||
NAVIGATION_TAB_NAME = 'abcd1'
|
NAVIGATION_TAB_NAME = 'abcd1'
|
||||||
self.mixin = NavigationCls()
|
self.mixin = NavigationCls()
|
||||||
|
|
||||||
class NothingNavigationCls(NavigationMixin, IntegrationPluginBase):
|
class NothingNavigationCls(NavigationMixin, InvenTreePlugin):
|
||||||
pass
|
pass
|
||||||
self.nothing_mixin = NothingNavigationCls()
|
self.nothing_mixin = NothingNavigationCls()
|
||||||
|
|
||||||
def test_function(self):
|
def test_function(self):
|
||||||
# check right configuration
|
# check right configuration
|
||||||
self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}, ])
|
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
|
# navigation name
|
||||||
self.assertEqual(self.mixin.navigation_name, 'abcd1')
|
self.assertEqual(self.mixin.navigation_name, 'abcd1')
|
||||||
self.assertEqual(self.nothing_mixin.navigation_name, '')
|
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):
|
class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||||
MIXIN_HUMAN_NAME = 'API calls'
|
MIXIN_HUMAN_NAME = 'API calls'
|
||||||
@ -150,8 +149,8 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
MIXIN_ENABLE_CHECK = 'has_api_call'
|
MIXIN_ENABLE_CHECK = 'has_api_call'
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
class MixinCls(APICallMixin, SettingsMixin, IntegrationPluginBase):
|
class MixinCls(APICallMixin, SettingsMixin, InvenTreePlugin):
|
||||||
PLUGIN_NAME = "Sample API Caller"
|
NAME = "Sample API Caller"
|
||||||
|
|
||||||
SETTINGS = {
|
SETTINGS = {
|
||||||
'API_TOKEN': {
|
'API_TOKEN': {
|
||||||
@ -167,22 +166,23 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
API_URL_SETTING = 'API_URL'
|
API_URL_SETTING = 'API_URL'
|
||||||
API_TOKEN_SETTING = 'API_TOKEN'
|
API_TOKEN_SETTING = 'API_TOKEN'
|
||||||
|
|
||||||
def get_external_url(self):
|
def get_external_url(self, simple: bool = True):
|
||||||
'''
|
'''
|
||||||
returns data from the sample endpoint
|
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()
|
self.mixin = MixinCls()
|
||||||
|
|
||||||
class WrongCLS(APICallMixin, IntegrationPluginBase):
|
class WrongCLS(APICallMixin, InvenTreePlugin):
|
||||||
pass
|
pass
|
||||||
self.mixin_wrong = WrongCLS()
|
self.mixin_wrong = WrongCLS()
|
||||||
|
|
||||||
class WrongCLS2(APICallMixin, IntegrationPluginBase):
|
class WrongCLS2(APICallMixin, InvenTreePlugin):
|
||||||
API_URL_SETTING = 'test'
|
API_URL_SETTING = 'test'
|
||||||
self.mixin_wrong2 = WrongCLS2()
|
self.mixin_wrong2 = WrongCLS2()
|
||||||
|
|
||||||
def test_function(self):
|
def test_base_setup(self):
|
||||||
|
"""Test that the base settings work"""
|
||||||
# check init
|
# check init
|
||||||
self.assertTrue(self.mixin.has_api_call)
|
self.assertTrue(self.mixin.has_api_call)
|
||||||
# api_url
|
# api_url
|
||||||
@ -192,6 +192,8 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
headers = self.mixin.api_headers
|
headers = self.mixin.api_headers
|
||||||
self.assertEqual(headers, {'Bearer': '', 'Content-Type': 'application/json'})
|
self.assertEqual(headers, {'Bearer': '', 'Content-Type': 'application/json'})
|
||||||
|
|
||||||
|
def test_args(self):
|
||||||
|
"""Test that building up args work"""
|
||||||
# api_build_url_args
|
# api_build_url_args
|
||||||
# 1 arg
|
# 1 arg
|
||||||
result = self.mixin.api_build_url_args({'a': 'b'})
|
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', ]})
|
result = self.mixin.api_build_url_args({'a': 'b', 'c': ['d', 'e', 'f', ]})
|
||||||
self.assertEqual(result, '?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
|
# api_call
|
||||||
result = self.mixin.get_external_url()
|
result = self.mixin.get_external_url()
|
||||||
self.assertTrue(result)
|
self.assertTrue(result)
|
||||||
self.assertIn('data', 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
|
# wrongly defined plugins should not load
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(MixinNotImplementedError):
|
||||||
self.mixin_wrong.has_api_call()
|
self.mixin_wrong.has_api_call()
|
||||||
|
|
||||||
# cover wrong token setting
|
# cover wrong token setting
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(MixinNotImplementedError):
|
||||||
self.mixin_wrong.has_api_call()
|
self.mixin_wrong2.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')
|
|
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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""sample implementation for ActionPlugin"""
|
"""sample implementation for ActionMixin"""
|
||||||
from plugin.action import ActionPlugin
|
from plugin import InvenTreePlugin
|
||||||
|
from plugin.mixins import ActionMixin
|
||||||
|
|
||||||
|
|
||||||
class SimpleActionPlugin(ActionPlugin):
|
class SimpleActionPlugin(ActionMixin, InvenTreePlugin):
|
||||||
"""
|
"""
|
||||||
An EXTREMELY simple action plugin which demonstrates
|
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"
|
ACTION_NAME = "simple"
|
||||||
|
|
||||||
def perform_action(self):
|
def perform_action(self):
|
||||||
|
@ -13,7 +13,7 @@ references model objects actually exist in the database.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from plugin import IntegrationPluginBase
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import BarcodeMixin
|
from plugin.mixins import BarcodeMixin
|
||||||
|
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
@ -22,9 +22,9 @@ from part.models import Part
|
|||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeBarcodePlugin(BarcodeMixin, IntegrationPluginBase):
|
class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||||
|
|
||||||
PLUGIN_NAME = "InvenTreeBarcode"
|
NAME = "InvenTreeBarcode"
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
"""
|
"""
|
||||||
|
@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
|
|
||||||
from allauth.account.models import EmailAddress
|
from allauth.account.models import EmailAddress
|
||||||
|
|
||||||
from plugin import IntegrationPluginBase
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import BulkNotificationMethod, SettingsMixin
|
from plugin.mixins import BulkNotificationMethod, SettingsMixin
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
|
|
||||||
@ -15,12 +15,12 @@ class PlgMixin:
|
|||||||
return CoreNotificationsPlugin
|
return CoreNotificationsPlugin
|
||||||
|
|
||||||
|
|
||||||
class CoreNotificationsPlugin(SettingsMixin, IntegrationPluginBase):
|
class CoreNotificationsPlugin(SettingsMixin, InvenTreePlugin):
|
||||||
"""
|
"""
|
||||||
Core notification methods for InvenTree
|
Core notification methods for InvenTree
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PLUGIN_NAME = "CoreNotificationsPlugin"
|
NAME = "CoreNotificationsPlugin"
|
||||||
AUTHOR = _('InvenTree contributors')
|
AUTHOR = _('InvenTree contributors')
|
||||||
DESCRIPTION = _('Integrated outgoing notificaton methods')
|
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 plugin.base.event.events import trigger_event
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import logging
|
__all__ = [
|
||||||
|
trigger_event,
|
||||||
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]
|
|
||||||
)
|
|
||||||
|
@ -8,12 +8,17 @@ import sysconfig
|
|||||||
import traceback
|
import traceback
|
||||||
import inspect
|
import inspect
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import AppRegistryNotReady
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
# region logging / errors
|
# region logging / errors
|
||||||
class IntegrationPluginError(Exception):
|
class IntegrationPluginError(Exception):
|
||||||
"""
|
"""
|
||||||
@ -200,7 +205,7 @@ def get_plugins(pkg, baseclass):
|
|||||||
Return a list of all modules under a given package.
|
Return a list of all modules under a given package.
|
||||||
|
|
||||||
- Modules must be a subclass of the provided 'baseclass'
|
- 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 = []
|
plugins = []
|
||||||
@ -212,8 +217,32 @@ def get_plugins(pkg, baseclass):
|
|||||||
# Iterate through each class in the module
|
# Iterate through each class in the module
|
||||||
for item in get_classes(mod):
|
for item in get_classes(mod):
|
||||||
plugin = item[1]
|
plugin = item[1]
|
||||||
if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME:
|
if issubclass(plugin, baseclass) and plugin.NAME:
|
||||||
plugins.append(plugin)
|
plugins.append(plugin)
|
||||||
|
|
||||||
return plugins
|
return plugins
|
||||||
# endregion
|
# 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
|
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 common.notifications import SingleNotificationMethod, BulkNotificationMethod
|
||||||
|
|
||||||
from ..builtin.action.mixins import ActionMixin
|
from ..base.action.mixins import ActionMixin
|
||||||
from ..builtin.barcodes.mixins import BarcodeMixin
|
from ..base.barcodes.mixins import BarcodeMixin
|
||||||
|
from ..base.event.mixins import EventMixin
|
||||||
|
from ..base.label.mixins import LabelPrintingMixin
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'APICallMixin',
|
'APICallMixin',
|
||||||
|
@ -4,14 +4,16 @@ Plugin model definitions
|
|||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
import warnings
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
|
|
||||||
from plugin import InvenTreePluginBase, registry
|
from plugin import InvenTreePlugin, registry
|
||||||
|
|
||||||
|
|
||||||
class PluginConfig(models.Model):
|
class PluginConfig(models.Model):
|
||||||
@ -59,7 +61,7 @@ class PluginConfig(models.Model):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
return self.plugin._mixinreg
|
return self.plugin._mixinreg
|
||||||
except (AttributeError, ValueError):
|
except (AttributeError, ValueError): # pragma: no cover
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# functions
|
# functions
|
||||||
@ -97,6 +99,8 @@ class PluginConfig(models.Model):
|
|||||||
if not reload:
|
if not reload:
|
||||||
if (self.active is False and self.__org_active is True) or \
|
if (self.active is False and self.__org_active is True) or \
|
||||||
(self.active is True and self.__org_active is False):
|
(self.active is True and self.__org_active is False):
|
||||||
|
if settings.PLUGIN_TESTING:
|
||||||
|
warnings.warn('A reload was triggered')
|
||||||
registry.reload_plugins()
|
registry.reload_plugins()
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
@ -141,7 +145,7 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
|
|||||||
|
|
||||||
if plugin:
|
if plugin:
|
||||||
|
|
||||||
if issubclass(plugin.__class__, InvenTreePluginBase):
|
if issubclass(plugin.__class__, InvenTreePlugin):
|
||||||
plugin = plugin.plugin_config()
|
plugin = plugin.plugin_config()
|
||||||
|
|
||||||
kwargs['settings'] = registry.mixins_settings.get(plugin.key, {})
|
kwargs['settings'] = registry.mixins_settings.get(plugin.key, {})
|
||||||
|
@ -2,33 +2,71 @@
|
|||||||
"""
|
"""
|
||||||
Base Class for InvenTree plugins
|
Base Class for InvenTree plugins
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import inspect
|
||||||
|
from datetime import datetime
|
||||||
|
import pathlib
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
from django.utils.text import slugify
|
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():
|
logger = logging.getLogger("inventree")
|
||||||
"""
|
|
||||||
Base class for a plugin
|
|
||||||
DO NOT USE THIS DIRECTLY, USE plugin.IntegrationPluginBase
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
pass
|
class MetaBase:
|
||||||
|
"""Base class for a plugins metadata"""
|
||||||
|
|
||||||
# Override the plugin name for each concrete plugin instance
|
# 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):
|
def plugin_name(self):
|
||||||
"""
|
"""
|
||||||
Name of plugin
|
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):
|
def plugin_slug(self):
|
||||||
"""
|
"""
|
||||||
@ -36,22 +74,35 @@ class InvenTreePluginBase():
|
|||||||
If not set plugin name slugified
|
If not set plugin name slugified
|
||||||
"""
|
"""
|
||||||
|
|
||||||
slug = getattr(self, 'PLUGIN_SLUG', None)
|
slug = self.get_meta_value('SLUG', 'PLUGIN_SLUG', None)
|
||||||
|
if not slug:
|
||||||
if slug is None:
|
|
||||||
slug = self.plugin_name()
|
slug = self.plugin_name()
|
||||||
|
|
||||||
return slugify(slug.lower())
|
return slugify(slug.lower())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slug(self):
|
||||||
|
"""
|
||||||
|
Slug of plugin
|
||||||
|
"""
|
||||||
|
return self.plugin_slug()
|
||||||
|
|
||||||
def plugin_title(self):
|
def plugin_title(self):
|
||||||
"""
|
"""
|
||||||
Title of plugin
|
Title of plugin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.PLUGIN_TITLE:
|
title = self.get_meta_value('TITLE', 'PLUGIN_TITLE', None)
|
||||||
return self.PLUGIN_TITLE
|
if title:
|
||||||
else:
|
return title
|
||||||
return self.plugin_name()
|
return self.plugin_name()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def human_name(self):
|
||||||
|
"""
|
||||||
|
Human readable name of plugin
|
||||||
|
"""
|
||||||
|
return self.plugin_title()
|
||||||
|
|
||||||
def plugin_config(self):
|
def plugin_config(self):
|
||||||
"""
|
"""
|
||||||
@ -83,11 +134,230 @@ class InvenTreePluginBase():
|
|||||||
return False # pragma: no cover
|
return False # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
# TODO @matmair remove after InvenTree 0.7.0 release
|
class MixinBase:
|
||||||
class InvenTreePlugin(InvenTreePluginBase):
|
|
||||||
"""
|
"""
|
||||||
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__()
|
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
|
import subprocess
|
||||||
|
|
||||||
from typing import OrderedDict
|
from typing import OrderedDict
|
||||||
from importlib import reload
|
from importlib import reload, metadata
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -22,16 +22,10 @@ from django.urls import clear_url_caches
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.text import slugify
|
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 maintenance_mode_on
|
||||||
from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
|
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
|
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.apps_loading = True # Marks if apps were reloaded yet
|
||||||
self.git_is_modern = True # Is a modern version of git available
|
self.git_is_modern = True # Is a modern version of git available
|
||||||
|
|
||||||
# integration specific
|
|
||||||
self.installed_apps = [] # Holds all added plugin_paths
|
self.installed_apps = [] # Holds all added plugin_paths
|
||||||
|
|
||||||
# mixins
|
# mixins
|
||||||
@ -129,7 +122,7 @@ class PluginsRegistry:
|
|||||||
log_error({error.path: error.message}, 'load')
|
log_error({error.path: error.message}, 'load')
|
||||||
blocked_plugin = error.path # we will not try to load this app again
|
blocked_plugin = error.path # we will not try to load this app again
|
||||||
|
|
||||||
# Initialize apps without any integration plugins
|
# Initialize apps without any plugins
|
||||||
self._clean_registry()
|
self._clean_registry()
|
||||||
self._clean_installed_apps()
|
self._clean_installed_apps()
|
||||||
self._activate_plugins(force_reload=True)
|
self._activate_plugins(force_reload=True)
|
||||||
@ -198,9 +191,7 @@ class PluginsRegistry:
|
|||||||
logger.info('Finished reloading plugins')
|
logger.info('Finished reloading plugins')
|
||||||
|
|
||||||
def collect_plugins(self):
|
def collect_plugins(self):
|
||||||
"""
|
"""Collect plugins from all possible ways of loading"""
|
||||||
Collect integration plugins from all possible ways of loading
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not settings.PLUGINS_ENABLED:
|
if not settings.PLUGINS_ENABLED:
|
||||||
# Plugins not enabled, do nothing
|
# Plugins not enabled, do nothing
|
||||||
@ -210,7 +201,7 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
# Collect plugins from paths
|
# Collect plugins from paths
|
||||||
for plugin in settings.PLUGIN_DIRS:
|
for plugin in settings.PLUGIN_DIRS:
|
||||||
modules = get_plugins(importlib.import_module(plugin), IntegrationPluginBase)
|
modules = get_plugins(importlib.import_module(plugin), InvenTreePlugin)
|
||||||
if modules:
|
if modules:
|
||||||
[self.plugin_modules.append(item) for item in modules]
|
[self.plugin_modules.append(item) for item in modules]
|
||||||
|
|
||||||
@ -236,7 +227,7 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
if settings.PLUGIN_FILE_CHECKED:
|
if settings.PLUGIN_FILE_CHECKED:
|
||||||
logger.info('Plugin file was already checked')
|
logger.info('Plugin file was already checked')
|
||||||
return
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
output = str(subprocess.check_output(['pip', 'install', '-U', '-r', settings.PLUGIN_FILE], cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')
|
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
|
# do not run again
|
||||||
settings.PLUGIN_FILE_CHECKED = True
|
settings.PLUGIN_FILE_CHECKED = True
|
||||||
|
return 'first_run'
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
@ -280,15 +272,15 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
logger.info('Starting plugin initialisation')
|
logger.info('Starting plugin initialisation')
|
||||||
|
|
||||||
# Initialize integration plugins
|
# Initialize plugins
|
||||||
for plugin in self.plugin_modules:
|
for plugin in self.plugin_modules:
|
||||||
# Check if package
|
# Check if package
|
||||||
was_packaged = getattr(plugin, 'is_package', False)
|
was_packaged = getattr(plugin, 'is_package', False)
|
||||||
|
|
||||||
# Check if activated
|
# Check if activated
|
||||||
# These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
|
# These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
|
||||||
plug_name = plugin.PLUGIN_NAME
|
plug_name = plugin.NAME
|
||||||
plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
|
plug_key = plugin.SLUG if getattr(plugin, 'SLUG', None) else plug_name
|
||||||
plug_key = slugify(plug_key) # keys are slugs!
|
plug_key = slugify(plug_key) # keys are slugs!
|
||||||
try:
|
try:
|
||||||
plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
|
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
|
# now we can be sure that an admin has activated the plugin
|
||||||
# TODO check more stuff -> as of Nov 2021 there are not many checks in place
|
# TODO check more stuff -> as of Nov 2021 there are not many checks in place
|
||||||
# but we could enhance those to check signatures, run the plugin against a whitelist etc.
|
# but we could enhance those to check signatures, run the plugin against a whitelist etc.
|
||||||
logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
|
logger.info(f'Loading plugin {plug_name}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plugin = plugin()
|
plugin = plugin()
|
||||||
@ -328,7 +320,7 @@ class PluginsRegistry:
|
|||||||
# log error and raise it -> disable plugin
|
# log error and raise it -> disable plugin
|
||||||
handle_error(error, log_name='init')
|
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
|
plugin.is_package = was_packaged
|
||||||
|
|
||||||
@ -343,7 +335,7 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
def _activate_plugins(self, force_reload=False):
|
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
|
:param force_reload: force reload base apps, defaults to False
|
||||||
:type force_reload: bool, optional
|
:type force_reload: bool, optional
|
||||||
@ -352,22 +344,20 @@ class PluginsRegistry:
|
|||||||
plugins = self.plugins.items()
|
plugins = self.plugins.items()
|
||||||
logger.info(f'Found {len(plugins)} active plugins')
|
logger.info(f'Found {len(plugins)} active plugins')
|
||||||
|
|
||||||
self.activate_integration_settings(plugins)
|
self.activate_plugin_settings(plugins)
|
||||||
self.activate_integration_schedule(plugins)
|
self.activate_plugin_schedule(plugins)
|
||||||
self.activate_integration_app(plugins, force_reload=force_reload)
|
self.activate_plugin_app(plugins, force_reload=force_reload)
|
||||||
|
|
||||||
def _deactivate_plugins(self):
|
def _deactivate_plugins(self):
|
||||||
"""
|
"""Run deactivation functions for all plugins"""
|
||||||
Run integration deactivation functions for all plugins
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.deactivate_integration_app()
|
self.deactivate_plugin_app()
|
||||||
self.deactivate_integration_schedule()
|
self.deactivate_plugin_schedule()
|
||||||
self.deactivate_integration_settings()
|
self.deactivate_plugin_settings()
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
# region mixin specific loading ...
|
# region mixin specific loading ...
|
||||||
def activate_integration_settings(self, plugins):
|
def activate_plugin_settings(self, plugins):
|
||||||
|
|
||||||
logger.info('Activating plugin settings')
|
logger.info('Activating plugin settings')
|
||||||
|
|
||||||
@ -378,7 +368,7 @@ class PluginsRegistry:
|
|||||||
plugin_setting = plugin.settings
|
plugin_setting = plugin.settings
|
||||||
self.mixins_settings[slug] = plugin_setting
|
self.mixins_settings[slug] = plugin_setting
|
||||||
|
|
||||||
def deactivate_integration_settings(self):
|
def deactivate_plugin_settings(self):
|
||||||
|
|
||||||
# collect all settings
|
# collect all settings
|
||||||
plugin_settings = {}
|
plugin_settings = {}
|
||||||
@ -389,7 +379,7 @@ class PluginsRegistry:
|
|||||||
# clear cache
|
# clear cache
|
||||||
self.mixins_settings = {}
|
self.mixins_settings = {}
|
||||||
|
|
||||||
def activate_integration_schedule(self, plugins):
|
def activate_plugin_schedule(self, plugins):
|
||||||
|
|
||||||
logger.info('Activating plugin tasks')
|
logger.info('Activating plugin tasks')
|
||||||
|
|
||||||
@ -433,14 +423,14 @@ class PluginsRegistry:
|
|||||||
# Database might not yet be ready
|
# Database might not yet be ready
|
||||||
logger.warning("activate_integration_schedule failed, database not ready")
|
logger.warning("activate_integration_schedule failed, database not ready")
|
||||||
|
|
||||||
def deactivate_integration_schedule(self):
|
def deactivate_plugin_schedule(self):
|
||||||
"""
|
"""
|
||||||
Deactivate ScheduleMixin
|
Deactivate ScheduleMixin
|
||||||
currently nothing is done
|
currently nothing is done
|
||||||
"""
|
"""
|
||||||
pass
|
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
|
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)
|
plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
|
||||||
except ValueError: # pragma: no cover
|
except ValueError: # pragma: no cover
|
||||||
# plugin is shipped as package
|
# plugin is shipped as package
|
||||||
plugin_path = plugin.PLUGIN_NAME
|
plugin_path = plugin.NAME
|
||||||
return plugin_path
|
return plugin_path
|
||||||
|
|
||||||
def deactivate_integration_app(self):
|
def deactivate_plugin_app(self):
|
||||||
"""
|
"""Deactivate AppMixin plugins - some magic required"""
|
||||||
Deactivate integration app - some magic required
|
|
||||||
"""
|
|
||||||
|
|
||||||
# unregister models from admin
|
# unregister models from admin
|
||||||
for plugin_path in self.installed_apps:
|
for plugin_path in self.installed_apps:
|
||||||
|
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
|
Sample plugin which responds to events
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from plugin import IntegrationPluginBase
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import EventMixin
|
from plugin.mixins import EventMixin
|
||||||
|
|
||||||
|
|
||||||
class EventPluginSample(EventMixin, IntegrationPluginBase):
|
class EventPluginSample(EventMixin, InvenTreePlugin):
|
||||||
"""
|
"""
|
||||||
A sample plugin which provides supports for triggered events
|
A sample plugin which provides supports for triggered events
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PLUGIN_NAME = "EventPlugin"
|
NAME = "EventPlugin"
|
||||||
PLUGIN_SLUG = "event"
|
SLUG = "event"
|
||||||
PLUGIN_TITLE = "Triggered Events"
|
TITLE = "Triggered Events"
|
||||||
|
|
||||||
def process_event(self, event, *args, **kwargs):
|
def process_event(self, event, *args, **kwargs):
|
||||||
""" Custom event processing """
|
""" Custom event processing """
|
@ -1,19 +1,19 @@
|
|||||||
"""sample implementation for IntegrationPlugin"""
|
"""sample implementation for IntegrationPlugin"""
|
||||||
from plugin import IntegrationPluginBase
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import UrlsMixin
|
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
|
Sample plugin for calling an external API
|
||||||
"""
|
"""
|
||||||
from plugin import IntegrationPluginBase
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import APICallMixin, SettingsMixin
|
from plugin.mixins import APICallMixin, SettingsMixin
|
||||||
|
|
||||||
|
|
||||||
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, IntegrationPluginBase):
|
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, InvenTreePlugin):
|
||||||
"""
|
"""
|
||||||
A small api call sample
|
A small api call sample
|
||||||
"""
|
"""
|
||||||
PLUGIN_NAME = "Sample API Caller"
|
NAME = "Sample API Caller"
|
||||||
|
|
||||||
SETTINGS = {
|
SETTINGS = {
|
||||||
'API_TOKEN': {
|
'API_TOKEN': {
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
"""sample of a broken python file that will be ignored on import"""
|
"""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"""
|
"""sample of a broken plugin"""
|
||||||
from plugin import IntegrationPluginBase
|
from plugin import InvenTreePlugin
|
||||||
|
|
||||||
|
|
||||||
class BrokenIntegrationPlugin(IntegrationPluginBase):
|
class BrokenIntegrationPlugin(InvenTreePlugin):
|
||||||
"""
|
"""
|
||||||
An very broken integration plugin
|
An very broken plugin
|
||||||
"""
|
"""
|
||||||
PLUGIN_NAME = 'Test'
|
NAME = 'Test'
|
||||||
PLUGIN_TITLE = 'Broken Plugin'
|
TITLE = 'Broken Plugin'
|
||||||
PLUGIN_SLUG = 'broken'
|
SLUG = 'broken'
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
@ -2,21 +2,21 @@
|
|||||||
Sample plugin which renders custom panels on certain pages
|
Sample plugin which renders custom panels on certain pages
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from plugin import IntegrationPluginBase
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import PanelMixin, SettingsMixin
|
from plugin.mixins import PanelMixin, SettingsMixin
|
||||||
|
|
||||||
from part.views import PartDetail
|
from part.views import PartDetail
|
||||||
from stock.views import StockLocationDetail
|
from stock.views import StockLocationDetail
|
||||||
|
|
||||||
|
|
||||||
class CustomPanelSample(PanelMixin, SettingsMixin, IntegrationPluginBase):
|
class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
|
||||||
"""
|
"""
|
||||||
A sample plugin which renders some custom panels.
|
A sample plugin which renders some custom panels.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PLUGIN_NAME = "CustomPanelExample"
|
NAME = "CustomPanelExample"
|
||||||
PLUGIN_SLUG = "panel"
|
SLUG = "panel"
|
||||||
PLUGIN_TITLE = "Custom Panel Example"
|
TITLE = "Custom Panel Example"
|
||||||
DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface"
|
DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface"
|
||||||
VERSION = "0.1"
|
VERSION = "0.1"
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
Sample implementations for IntegrationPlugin
|
Sample implementations for IntegrationPlugin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from plugin import IntegrationPluginBase
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin
|
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin
|
||||||
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@ -10,14 +10,14 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.urls import include, re_path
|
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"
|
NAME = "SampleIntegrationPlugin"
|
||||||
PLUGIN_SLUG = "sample"
|
SLUG = "sample"
|
||||||
PLUGIN_TITLE = "Sample Plugin"
|
TITLE = "Sample Plugin"
|
||||||
|
|
||||||
NAVIGATION_TAB_NAME = "Sample Nav"
|
NAVIGATION_TAB_NAME = "Sample Nav"
|
||||||
NAVIGATION_TAB_ICON = 'fas fa-plus'
|
NAVIGATION_TAB_ICON = 'fas fa-plus'
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
Sample plugin which supports task scheduling
|
Sample plugin which supports task scheduling
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from plugin import IntegrationPluginBase
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import ScheduleMixin, SettingsMixin
|
from plugin.mixins import ScheduleMixin, SettingsMixin
|
||||||
|
|
||||||
|
|
||||||
@ -15,14 +15,14 @@ def print_world():
|
|||||||
print("World") # pragma: no cover
|
print("World") # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase):
|
class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, InvenTreePlugin):
|
||||||
"""
|
"""
|
||||||
A sample plugin which provides support for scheduled tasks
|
A sample plugin which provides support for scheduled tasks
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PLUGIN_NAME = "ScheduledTasksPlugin"
|
NAME = "ScheduledTasksPlugin"
|
||||||
PLUGIN_SLUG = "schedule"
|
SLUG = "schedule"
|
||||||
PLUGIN_TITLE = "Scheduled Tasks"
|
TITLE = "Scheduled Tasks"
|
||||||
|
|
||||||
SCHEDULED_TASKS = {
|
SCHEDULED_TASKS = {
|
||||||
'member': {
|
'member': {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from plugin import registry, IntegrationPluginBase
|
from plugin import registry, InvenTreePlugin
|
||||||
from plugin.helpers import MixinImplementationError
|
from plugin.helpers import MixinImplementationError
|
||||||
from plugin.registry import call_function
|
from plugin.registry import call_function
|
||||||
from plugin.mixins import ScheduleMixin
|
from plugin.mixins import ScheduleMixin
|
||||||
@ -45,16 +45,20 @@ class ExampleScheduledTaskPluginTests(TestCase):
|
|||||||
|
|
||||||
def test_calling(self):
|
def test_calling(self):
|
||||||
"""check if a function can be called without errors"""
|
"""check if a function can be called without errors"""
|
||||||
|
# Check with right parameters
|
||||||
self.assertEqual(call_function('schedule', 'member_func'), False)
|
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):
|
class ScheduledTaskPluginTests(TestCase):
|
||||||
""" Tests for ScheduledTaskPluginTests mixin base """
|
""" Tests for ScheduledTaskPluginTests mixin base """
|
||||||
|
|
||||||
def test_init(self):
|
def test_init(self):
|
||||||
"""Check that all MixinImplementationErrors raise"""
|
"""Check that all MixinImplementationErrors raise"""
|
||||||
class Base(ScheduleMixin, IntegrationPluginBase):
|
class Base(ScheduleMixin, InvenTreePlugin):
|
||||||
PLUGIN_NAME = 'APlugin'
|
NAME = 'APlugin'
|
||||||
|
|
||||||
class NoSchedules(Base):
|
class NoSchedules(Base):
|
||||||
"""Plugin without schedules"""
|
"""Plugin without schedules"""
|
||||||
|
@ -96,8 +96,10 @@ class PluginConfigInstallSerializer(serializers.Serializer):
|
|||||||
install_name.append(f'{packagename}@{url}')
|
install_name.append(f'{packagename}@{url}')
|
||||||
else:
|
else:
|
||||||
install_name.append(url)
|
install_name.append(url)
|
||||||
else:
|
else: # pragma: no cover
|
||||||
# using a custom package repositories
|
# 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('-i')
|
||||||
install_name.append(url)
|
install_name.append(url)
|
||||||
install_name.append(packagename)
|
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 pathlib import Path
|
||||||
|
|
||||||
from django import template
|
|
||||||
from django.template.loaders.filesystem import Loader as FilesystemLoader
|
from django.template.loaders.filesystem import Loader as FilesystemLoader
|
||||||
|
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
|
||||||
|
|
||||||
|
|
||||||
class PluginTemplateLoader(FilesystemLoader):
|
class PluginTemplateLoader(FilesystemLoader):
|
||||||
"""
|
"""
|
||||||
A custom template loader which allows loading of templates from installed plugins.
|
A custom template loader which allows loading of templates from installed plugins.
|
||||||
@ -38,25 +31,3 @@ class PluginTemplateLoader(FilesystemLoader):
|
|||||||
template_dirs.append(new_path)
|
template_dirs.append(new_path)
|
||||||
|
|
||||||
return tuple(template_dirs)
|
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()
|
@register.simple_tag()
|
||||||
def plugin_list(*args, **kwargs):
|
def plugin_list(*args, **kwargs):
|
||||||
"""
|
"""
|
||||||
List of all installed integration plugins
|
List of all installed plugins
|
||||||
"""
|
"""
|
||||||
return registry.plugins
|
return registry.plugins
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ def plugin_list(*args, **kwargs):
|
|||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def inactive_plugin_list(*args, **kwargs):
|
def inactive_plugin_list(*args, **kwargs):
|
||||||
"""
|
"""
|
||||||
List of all inactive integration plugins
|
List of all inactive plugins
|
||||||
"""
|
"""
|
||||||
return registry.plugins_inactive
|
return registry.plugins_inactive
|
||||||
|
|
||||||
|
@ -45,6 +45,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
|
|||||||
}, expected_code=201).data
|
}, expected_code=201).data
|
||||||
self.assertEqual(data['success'], True)
|
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
|
# invalid tries
|
||||||
# no input
|
# no input
|
||||||
self.post(url, {}, expected_code=400)
|
self.post(url, {}, expected_code=400)
|
||||||
@ -124,3 +132,30 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
|
|||||||
'_save': 'Save',
|
'_save': 'Save',
|
||||||
}, follow=True)
|
}, follow=True)
|
||||||
self.assertEqual(response.status_code, 200)
|
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
|
Unit tests for plugins
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from plugin.samples.integration.sample import SampleIntegrationPlugin
|
from plugin.samples.integration.sample import SampleIntegrationPlugin
|
||||||
from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
|
from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
|
||||||
import plugin.templatetags.plugin_extras as plugin_tags
|
import plugin.templatetags.plugin_extras as plugin_tags
|
||||||
from plugin import registry, InvenTreePluginBase
|
from plugin import registry, InvenTreePlugin, IntegrationPluginBase
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginTagTests(TestCase):
|
class PluginTagTests(TestCase):
|
||||||
@ -79,3 +55,118 @@ class PluginTagTests(TestCase):
|
|||||||
def test_tag_plugin_errors(self):
|
def test_tag_plugin_errors(self):
|
||||||
"""test that all errors are listed"""
|
"""test that all errors are listed"""
|
||||||
self.assertEqual(plugin_tags.plugin_errors(), registry.errors)
|
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
|
||||||
|
"""
|
||||||
|
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)
|
owner_type=content_type_id)
|
||||||
except Owner.DoesNotExist:
|
except Owner.DoesNotExist:
|
||||||
pass
|
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
|
return owner
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user