Merge branch 'inventree:master' into not-working-tests

This commit is contained in:
Matthias Mair 2022-05-15 17:05:17 +02:00 committed by GitHub
commit bfd8b1e3b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 1197 additions and 1023 deletions

View File

@ -12,7 +12,7 @@ on:
- l10*
env:
python_version: 3.8
python_version: 3.9
node_version: 16
server_start_sleep: 60

View File

@ -19,7 +19,7 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue seems stale. Please react to show this is still important.'
stale-pr-message: 'This PR seems stale. Please react to show this is still important.'
stale-issue-label: 'no-activity'
stale-pr-label: 'no-activity'
stale-issue-label: 'inactive'
stale-pr-label: 'inactive'
start-date: '2022-01-01'
exempt-all-milestones: true

View File

@ -21,10 +21,10 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Set up Python 3.7
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.7
python-version: 3.9
- name: Install Dependencies
run: |
sudo apt-get update

View File

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

View File

@ -13,15 +13,11 @@ from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from .views import AjaxView
from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
from .status import is_worker_running
from plugin import registry
class InfoView(AjaxView):
""" Simple JSON endpoint for InvenTree information.
@ -119,40 +115,3 @@ class AttachmentMixin:
attachment = serializer.save()
attachment.user = self.request.user
attachment.save()
class ActionPluginView(APIView):
"""
Endpoint for running custom action plugins.
"""
permission_classes = [
permissions.IsAuthenticated,
]
def post(self, request, *args, **kwargs):
action = request.data.get('action', None)
data = request.data.get('data', None)
if action is None:
return Response({
'error': _("No action specified")
})
action_plugins = registry.with_mixin('action')
for plugin in action_plugins:
if plugin.action_name() == action:
# TODO @matmair use easier syntax once InvenTree 0.7.0 is released
plugin.init(request.user, data=data)
plugin.perform_action()
return Response(plugin.get_response())
# If we got to here, no matching action was found
return Response({
'error': _("No matching action found"),
"action": action,
})

View File

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

View File

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

View File

@ -32,9 +32,6 @@ class MiddlewareTests(TestCase):
# logout
self.client.logout()
# check that static files go through
self.check_path('/static/css/inventree.css')
# check that account things go through
self.check_path(reverse('account_login'))
@ -62,5 +59,8 @@ class MiddlewareTests(TestCase):
# request with token
self.check_path(reverse('settings.js'), HTTP_Authorization=f'Token {token}')
# Request with broken token
self.check_path(reverse('settings.js'), 401, HTTP_Authorization='Token abcd123')
# should still fail without token
self.check_path(reverse('settings.js'), 401)

View File

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

View File

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

View File

@ -271,9 +271,9 @@ class BaseInvenTreeSetting(models.Model):
plugin = kwargs.get('plugin', None)
if plugin is not None:
from plugin import InvenTreePluginBase
from plugin import InvenTreePlugin
if issubclass(plugin.__class__, InvenTreePluginBase):
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
filters['plugin'] = plugin
@ -375,9 +375,9 @@ class BaseInvenTreeSetting(models.Model):
filters['user'] = user
if plugin is not None:
from plugin import InvenTreePluginBase
from plugin import InvenTreePlugin
if issubclass(plugin.__class__, InvenTreePluginBase):
if issubclass(plugin.__class__, InvenTreePlugin):
filters['plugin'] = plugin.plugin_config()
else:
filters['plugin'] = plugin

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,14 +4,16 @@ Plugin model definitions
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import warnings
from django.utils.translation import gettext_lazy as _
from django.db import models
from django.contrib.auth.models import User
from django.conf import settings
import common.models
from plugin import InvenTreePluginBase, registry
from plugin import InvenTreePlugin, registry
class PluginConfig(models.Model):
@ -59,7 +61,7 @@ class PluginConfig(models.Model):
try:
return self.plugin._mixinreg
except (AttributeError, ValueError):
except (AttributeError, ValueError): # pragma: no cover
return {}
# functions
@ -97,6 +99,8 @@ class PluginConfig(models.Model):
if not reload:
if (self.active is False and self.__org_active is True) or \
(self.active is True and self.__org_active is False):
if settings.PLUGIN_TESTING:
warnings.warn('A reload was triggered')
registry.reload_plugins()
return ret
@ -141,7 +145,7 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
if plugin:
if issubclass(plugin.__class__, InvenTreePluginBase):
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
kwargs['settings'] = registry.mixins_settings.get(plugin.key, {})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
Sample implementations for IntegrationPlugin
"""
from plugin import IntegrationPluginBase
from plugin import InvenTreePlugin
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin
from django.http import HttpResponse
@ -10,14 +10,14 @@ from django.utils.translation import gettext_lazy as _
from django.urls import include, re_path
class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase):
class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, InvenTreePlugin):
"""
A full integration plugin example
A full plugin example
"""
PLUGIN_NAME = "SampleIntegrationPlugin"
PLUGIN_SLUG = "sample"
PLUGIN_TITLE = "Sample Plugin"
NAME = "SampleIntegrationPlugin"
SLUG = "sample"
TITLE = "Sample Plugin"
NAVIGATION_TAB_NAME = "Sample Nav"
NAVIGATION_TAB_ICON = 'fas fa-plus'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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