Merge branch 'master' of github.com:inventree/InvenTree

This commit is contained in:
Oliver Walters 2022-05-16 23:15:27 +10:00
commit 691b3f7d8d
132 changed files with 14568 additions and 13571 deletions

View File

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

View File

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

View File

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

View File

@ -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.

View File

@ -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,
})

View File

@ -142,6 +142,18 @@ class InvenTreeAPITestCase(APITestCase):
return response return response
def put(self, url, data, expected_code=None, format='json'):
"""
Issue a PUT request
"""
response = self.client.put(url, data=data, format=format)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def options(self, url, expected_code=None): def options(self, url, expected_code=None):
""" """
Issue an OPTIONS request Issue an OPTIONS request

View File

@ -4,11 +4,16 @@ InvenTree API version information
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 48 INVENTREE_API_VERSION = 49
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v49 -> 2022-05-09 : https://github.com/inventree/InvenTree/pull/2957
- Allows filtering of plugin list by 'active' status
- Allows filtering of plugin list by 'mixin' support
- Adds endpoint to "identify" or "locate" stock items and locations (using plugins)
v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977 v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977
- Adds "export to file" functionality for PurchaseOrder API endpoint - Adds "export to file" functionality for PurchaseOrder API endpoint
- Adds "export to file" functionality for SalesOrder API endpoint - Adds "export to file" functionality for SalesOrder API endpoint

View File

@ -131,7 +131,7 @@ class InvenTreeConfig(AppConfig):
update = True update = True
# Backend currency has changed? # Backend currency has changed?
if not base_currency == backend.base_currency: if base_currency != backend.base_currency:
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}") logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
update = True update = True
@ -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

View File

@ -224,7 +224,7 @@ def increment(n):
groups = result.groups() groups = result.groups()
# If we cannot match the regex, then simply return the provided value # If we cannot match the regex, then simply return the provided value
if not len(groups) == 2: if len(groups) != 2:
return value return value
prefix, number = groups prefix, number = groups
@ -536,7 +536,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
raise ValidationError([_("No serial numbers found")]) raise ValidationError([_("No serial numbers found")])
# The number of extracted serial numbers must match the expected quantity # The number of extracted serial numbers must match the expected quantity
if not expected_quantity == len(numbers): if expected_quantity != len(numbers):
raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)]) raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
return numbers return numbers
@ -575,7 +575,7 @@ def validateFilterString(value, model=None):
pair = group.split('=') pair = group.split('=')
if not len(pair) == 2: if len(pair) != 2:
raise ValidationError( raise ValidationError(
"Invalid group: {g}".format(g=group) "Invalid group: {g}".format(g=group)
) )

View File

@ -250,7 +250,7 @@ class InvenTreeMetadata(SimpleMetadata):
field_info = super().get_field_info(field) field_info = super().get_field_info(field)
# If a default value is specified for the serializer field, add it! # If a default value is specified for the serializer field, add it!
if 'default' not in field_info and not field.default == empty: if 'default' not in field_info and field.default != empty:
field_info['default'] = field.get_default() field_info['default'] = field.get_default()
# Force non-nullable fields to read as "required" # Force non-nullable fields to read as "required"

View File

@ -3,7 +3,6 @@
from django.conf import settings from django.conf import settings
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse_lazy, Resolver404 from django.urls import reverse_lazy, Resolver404
from django.urls import include, re_path from django.urls import include, re_path
@ -71,10 +70,6 @@ class AuthRequiredMiddleware(object):
# No authorization was found for the request # No authorization was found for the request
if not authorized: if not authorized:
# A logout request will redirect the user to the login screen
if request.path_info == reverse_lazy('account_logout'):
return HttpResponseRedirect(reverse_lazy('account_login'))
path = request.path_info path = request.path_info
# List of URL endpoints we *do not* want to redirect to # List of URL endpoints we *do not* want to redirect to

View File

@ -259,7 +259,7 @@ class InvenTreeAttachment(models.Model):
new_file = os.path.abspath(new_file) new_file = os.path.abspath(new_file)
# Check that there are no directory tricks going on... # Check that there are no directory tricks going on...
if not os.path.dirname(new_file) == attachment_dir: if os.path.dirname(new_file) != attachment_dir:
logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'") logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'")
raise ValidationError(_("Invalid attachment directory")) raise ValidationError(_("Invalid attachment directory"))

View File

@ -658,7 +658,7 @@ AUTH_PASSWORD_VALIDATORS = [
EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', []) EXTRA_URL_SCHEMES = CONFIG.get('extra_url_schemes', [])
if not type(EXTRA_URL_SCHEMES) in [list]: # pragma: no cover if type(EXTRA_URL_SCHEMES) not in [list]: # pragma: no cover
logger.warning("extra_url_schemes not correctly formatted") logger.warning("extra_url_schemes not correctly formatted")
EXTRA_URL_SCHEMES = [] EXTRA_URL_SCHEMES = []

View File

@ -126,8 +126,8 @@ def heartbeat():
try: try:
from django_q.models import Success from django_q.models import Success
logger.info("Could not perform heartbeat task - App registry not ready")
except AppRegistryNotReady: # pragma: no cover except AppRegistryNotReady: # pragma: no cover
logger.info("Could not perform heartbeat task - App registry not ready")
return return
threshold = timezone.now() - timedelta(minutes=30) threshold = timezone.now() - timedelta(minutes=30)
@ -204,7 +204,7 @@ def check_for_updates():
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest') response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
if not response.status_code == 200: if response.status_code != 200:
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
data = json.loads(response.text) data = json.loads(response.text)
@ -216,13 +216,13 @@ def check_for_updates():
match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag) match = re.match(r"^.*(\d+)\.(\d+)\.(\d+).*$", tag)
if not len(match.groups()) == 3: if len(match.groups()) != 3:
logger.warning(f"Version '{tag}' did not match expected pattern") logger.warning(f"Version '{tag}' did not match expected pattern")
return return
latest_version = [int(x) for x in match.groups()] latest_version = [int(x) for x in match.groups()]
if not len(latest_version) == 3: if len(latest_version) != 3:
raise ValueError(f"Version '{tag}' is not correct format") raise ValueError(f"Version '{tag}' is not correct format")
logger.info(f"Latest InvenTree version: '{tag}'") logger.info(f"Latest InvenTree version: '{tag}'")

View File

@ -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 """

View File

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

View File

@ -451,6 +451,11 @@ class TestSettings(TestCase):
self.user_mdl = get_user_model() self.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())

View 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)),

View File

@ -627,7 +627,7 @@ class SetPasswordView(AjaxUpdateView):
if valid: if valid:
# Passwords must match # Passwords must match
if not p1 == p2: if p1 != p2:
error = _('Password fields must match') error = _('Password fields must match')
form.add_error('enter_password', error) form.add_error('enter_password', error)
form.add_error('confirm_password', error) form.add_error('confirm_password', error)

View File

@ -777,7 +777,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
if not output.is_building: if not output.is_building:
raise ValidationError(_("Build output is already completed")) raise ValidationError(_("Build output is already completed"))
if not output.build == self: if output.build != self:
raise ValidationError(_("Build output does not match Build Order")) raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output # Unallocate all build items against the output
@ -1240,7 +1240,7 @@ class BuildItem(models.Model):
}) })
# Quantity must be 1 for serialized stock # Quantity must be 1 for serialized stock
if self.stock_item.serialized and not self.quantity == 1: if self.stock_item.serialized and self.quantity != 1:
raise ValidationError({ raise ValidationError({
'quantity': _('Quantity must be 1 for serialized stock') 'quantity': _('Quantity must be 1 for serialized stock')
}) })

View File

@ -199,7 +199,7 @@ class FileManager:
try: try:
# Excel import casts number-looking-items into floats, which is annoying # Excel import casts number-looking-items into floats, which is annoying
if item == int(item) and not str(item) == str(int(item)): if item == int(item) and str(item) != str(int(item)):
data[idx] = int(item) data[idx] = int(item)
except ValueError: except ValueError:
pass pass

View File

@ -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
@ -1802,10 +1802,8 @@ class WebhookEndpoint(models.Model):
def process_webhook(self): def process_webhook(self):
if self.token: if self.token:
self.verify = VerificationMethod.TOKEN self.verify = VerificationMethod.TOKEN
# TODO make a object-setting
if self.secret: if self.secret:
self.verify = VerificationMethod.HMAC self.verify = VerificationMethod.HMAC
# TODO make a object-setting
return True return True
def validate_token(self, payload, headers, request): def validate_token(self, payload, headers, request):

View File

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

View File

@ -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 from .models import InvenTreeSetting, InvenTreeUserSetting, WebhookEndpoint, WebhookMessage, NotificationEntry
from .api import WebhookView from .api import WebhookView
@ -132,7 +133,7 @@ class SettingsTest(TestCase):
if description is None: if description is None:
raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover
if not key == key.upper(): if key != key.upper():
raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover
def test_defaults(self): def test_defaults(self):
@ -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"""
... ...

View File

@ -494,7 +494,7 @@ class SupplierPart(models.Model):
# Ensure that the linked manufacturer_part points to the same part! # Ensure that the linked manufacturer_part points to the same part!
if self.manufacturer_part and self.part: if self.manufacturer_part and self.part:
if not self.manufacturer_part.part == self.part: if self.manufacturer_part.part != self.part:
raise ValidationError({ raise ValidationError({
'manufacturer_part': _("Linked manufacturer part must reference the same base part"), 'manufacturer_part': _("Linked manufacturer part must reference the same base part"),
}) })

View File

@ -162,7 +162,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
self.response = response self.response = response
# Check for valid response code # Check for valid response code
if not response.status_code == 200: if response.status_code != 200:
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code)) form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
return return

View File

@ -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,

View File

@ -105,7 +105,7 @@ class LabelConfig(AppConfig):
# File already exists - let's see if it is the "same", # File already exists - let's see if it is the "same",
# or if we need to overwrite it with a newer copy! # or if we need to overwrite it with a newer copy!
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
logger.info(f"Hash differs for '{filename}'") logger.info(f"Hash differs for '{filename}'")
to_copy = True to_copy = True
@ -199,7 +199,7 @@ class LabelConfig(AppConfig):
# File already exists - let's see if it is the "same", # File already exists - let's see if it is the "same",
# or if we need to overwrite it with a newer copy! # or if we need to overwrite it with a newer copy!
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
logger.info(f"Hash differs for '{filename}'") logger.info(f"Hash differs for '{filename}'")
to_copy = True to_copy = True
@ -291,7 +291,7 @@ class LabelConfig(AppConfig):
if os.path.exists(dst_file): if os.path.exists(dst_file):
# File already exists - let's see if it is the "same" # File already exists - let's see if it is the "same"
if not hashFile(dst_file) == hashFile(src_file): # pragma: no cover if hashFile(dst_file) != hashFile(src_file): # pragma: no cover
logger.info(f"Hash differs for '{filename}'") logger.info(f"Hash differs for '{filename}'")
to_copy = True to_copy = True

View File

@ -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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,8 @@ import order.serializers as serializers
from part.models import Part from part.models import Part
from users.models import Owner from users.models import Owner
from plugin.serializers import MetadataSerializer
class GeneralExtraLineList: class GeneralExtraLineList:
""" """
@ -347,6 +349,15 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
serializer_class = serializers.PurchaseOrderIssueSerializer serializer_class = serializers.PurchaseOrderIssueSerializer
class PurchaseOrderMetadata(generics.RetrieveUpdateAPIView):
"""API endpoint for viewing / updating PurchaseOrder metadata"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(models.PurchaseOrder, *args, **kwargs)
queryset = models.PurchaseOrder.objects.all()
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView): class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
""" """
API endpoint to receive stock items against a purchase order. API endpoint to receive stock items against a purchase order.
@ -916,6 +927,15 @@ class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
serializer_class = serializers.SalesOrderCompleteSerializer serializer_class = serializers.SalesOrderCompleteSerializer
class SalesOrderMetadata(generics.RetrieveUpdateAPIView):
"""API endpoint for viewing / updating SalesOrder metadata"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(models.SalesOrder, *args, **kwargs)
queryset = models.SalesOrder.objects.all()
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView): class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
""" """
API endpoint to allocation stock items against a SalesOrder, API endpoint to allocation stock items against a SalesOrder,
@ -1138,10 +1158,13 @@ order_api_urls = [
# Individual purchase order detail URLs # Individual purchase order detail URLs
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'), re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'), re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
re_path(r'^metadata/', PurchaseOrderMetadata.as_view(), name='api-po-metadata'),
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
# PurchaseOrder detail API endpoint
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'), re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
])), ])),
@ -1178,10 +1201,13 @@ order_api_urls = [
# Sales order detail view # Sales order detail view
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'), re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
re_path(r'^metadata/', SalesOrderMetadata.as_view(), name='api-so-metadata'),
# SalesOrder detail endpoint
re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'), re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'),
])), ])),

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.13 on 2022-05-16 11:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0066_alter_purchaseorder_supplier'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='salesorder',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -30,7 +30,9 @@ from users import models as UserModels
from part import models as PartModels from part import models as PartModels
from stock import models as stock_models from stock import models as stock_models
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from plugin.events import trigger_event from plugin.events import trigger_event
from plugin.models import MetadataMixin
import InvenTree.helpers import InvenTree.helpers
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
@ -97,7 +99,7 @@ def get_next_so_number():
return reference return reference
class Order(ReferenceIndexingMixin): class Order(MetadataMixin, ReferenceIndexingMixin):
""" Abstract model for an order. """ Abstract model for an order.
Instances of this class: Instances of this class:
@ -306,7 +308,7 @@ class PurchaseOrder(Order):
except ValueError: except ValueError:
raise ValidationError({'quantity': _("Invalid quantity provided")}) raise ValidationError({'quantity': _("Invalid quantity provided")})
if not supplier_part.supplier == self.supplier: if supplier_part.supplier != self.supplier:
raise ValidationError({'supplier': _("Part supplier must match PO supplier")}) raise ValidationError({'supplier': _("Part supplier must match PO supplier")})
if group: if group:
@ -445,7 +447,7 @@ class PurchaseOrder(Order):
if barcode is None: if barcode is None:
barcode = '' barcode = ''
if not self.status == PurchaseOrderStatus.PLACED: if self.status != PurchaseOrderStatus.PLACED:
raise ValidationError( raise ValidationError(
"Lines can only be received against an order marked as 'PLACED'" "Lines can only be received against an order marked as 'PLACED'"
) )
@ -729,7 +731,7 @@ class SalesOrder(Order):
Return True if this order can be cancelled Return True if this order can be cancelled
""" """
if not self.status == SalesOrderStatus.PENDING: if self.status != SalesOrderStatus.PENDING:
return False return False
return True return True
@ -1295,7 +1297,7 @@ class SalesOrderAllocation(models.Model):
raise ValidationError({'item': _('Stock item has not been assigned')}) raise ValidationError({'item': _('Stock item has not been assigned')})
try: try:
if not self.line.part == self.item.part: if self.line.part != self.item.part:
errors['item'] = _('Cannot allocate stock item to a line with a different part') errors['item'] = _('Cannot allocate stock item to a line with a different part')
except PartModels.Part.DoesNotExist: except PartModels.Part.DoesNotExist:
errors['line'] = _('Cannot allocate stock to a line without a part') errors['line'] = _('Cannot allocate stock to a line without a part')
@ -1310,7 +1312,7 @@ class SalesOrderAllocation(models.Model):
if self.quantity <= 0: if self.quantity <= 0:
errors['quantity'] = _('Allocation quantity must be greater than zero') errors['quantity'] = _('Allocation quantity must be greater than zero')
if self.item.serial and not self.quantity == 1: if self.item.serial and self.quantity != 1:
errors['quantity'] = _('Quantity must be 1 for serialized stock item') errors['quantity'] = _('Quantity must be 1 for serialized stock item')
if self.line.order != self.shipment.order: if self.line.order != self.shipment.order:

View File

@ -306,6 +306,22 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(po.status, PurchaseOrderStatus.PLACED) self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
def test_po_metadata(self):
url = reverse('api-po-metadata', kwargs={'pk': 1})
self.patch(
url,
{
'metadata': {
'yam': 'yum',
}
},
expected_code=200
)
order = models.PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.get_metadata('yam'), 'yum')
class PurchaseOrderReceiveTest(OrderTest): class PurchaseOrderReceiveTest(OrderTest):
""" """
@ -875,6 +891,22 @@ class SalesOrderTest(OrderTest):
self.assertEqual(so.status, SalesOrderStatus.CANCELLED) self.assertEqual(so.status, SalesOrderStatus.CANCELLED)
def test_so_metadata(self):
url = reverse('api-so-metadata', kwargs={'pk': 1})
self.patch(
url,
{
'metadata': {
'xyz': 'abc',
}
},
expected_code=200
)
order = models.SalesOrder.objects.get(pk=1)
self.assertEqual(order.get_metadata('xyz'), 'abc')
class SalesOrderAllocateTest(OrderTest): class SalesOrderAllocateTest(OrderTest):
""" """

View File

@ -44,6 +44,7 @@ from stock.models import StockItem, StockLocation
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from build.models import Build, BuildItem from build.models import Build, BuildItem
import order.models import order.models
from plugin.serializers import MetadataSerializer
from . import serializers as part_serializers from . import serializers as part_serializers
@ -203,6 +204,15 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
return response return response
class CategoryMetadata(generics.RetrieveUpdateAPIView):
"""API endpoint for viewing / updating PartCategory metadata"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(PartCategory, *args, **kwargs)
queryset = PartCategory.objects.all()
class CategoryParameterList(generics.ListAPIView): class CategoryParameterList(generics.ListAPIView):
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects. """ API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@ -587,6 +597,17 @@ class PartScheduling(generics.RetrieveAPIView):
return Response(schedule) return Response(schedule)
class PartMetadata(generics.RetrieveUpdateAPIView):
"""
API endpoint for viewing / updating Part metadata
"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(Part, *args, **kwargs)
queryset = Part.objects.all()
class PartSerialNumberDetail(generics.RetrieveAPIView): class PartSerialNumberDetail(generics.RetrieveAPIView):
""" """
API endpoint for returning extra serial number information about a particular part API endpoint for returning extra serial number information about a particular part
@ -1912,7 +1933,15 @@ part_api_urls = [
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'), re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
re_path(r'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'), re_path(r'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
re_path(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'), # Category detail endpoints
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'),
# PartCategory detail endpoint
re_path(r'^.*$', CategoryDetail.as_view(), name='api-part-category-detail'),
])),
path('', CategoryList.as_view(), name='api-part-category-list'), path('', CategoryList.as_view(), name='api-part-category-list'),
])), ])),
@ -1973,6 +2002,9 @@ part_api_urls = [
# Endpoint for validating a BOM for the specific Part # Endpoint for validating a BOM for the specific Part
re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'), re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
# Part metadata
re_path(r'^metadata/', PartMetadata.as_view(), name='api-part-metadata'),
# Part detail endpoint # Part detail endpoint
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'), re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
])), ])),

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.13 on 2022-05-16 08:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0075_auto_20211128_0151'),
]
operations = [
migrations.AddField(
model_name='part',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='partcategory',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -46,29 +46,29 @@ from common.models import InvenTreeSetting
from InvenTree import helpers from InvenTree import helpers
from InvenTree import validators from InvenTree import validators
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
import common.models
from build import models as BuildModels from build import models as BuildModels
from order import models as OrderModels from order import models as OrderModels
from company.models import SupplierPart from company.models import SupplierPart
import part.settings as part_settings
from stock import models as StockModels from stock import models as StockModels
import common.models from plugin.models import MetadataMixin
import part.settings as part_settings
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
class PartCategory(InvenTreeTree): class PartCategory(MetadataMixin, InvenTreeTree):
""" PartCategory provides hierarchical organization of Part objects. """ PartCategory provides hierarchical organization of Part objects.
Attributes: Attributes:
@ -327,7 +327,7 @@ class PartManager(TreeManager):
@cleanup.ignore @cleanup.ignore
class Part(MPTTModel): class Part(MetadataMixin, MPTTModel):
""" The Part object represents an abstract part, the 'concept' of an actual entity. """ The Part object represents an abstract part, the 'concept' of an actual entity.
An actual physical instance of a Part is a StockItem which is treated separately. An actual physical instance of a Part is a StockItem which is treated separately.
@ -444,7 +444,7 @@ class Part(MPTTModel):
previous = Part.objects.get(pk=self.pk) previous = Part.objects.get(pk=self.pk)
# Image has been changed # Image has been changed
if previous.image is not None and not self.image == previous.image: if previous.image is not None and self.image != previous.image:
# Are there any (other) parts which reference the image? # Are there any (other) parts which reference the image?
n_refs = Part.objects.filter(image=previous.image).exclude(pk=self.pk).count() n_refs = Part.objects.filter(image=previous.image).exclude(pk=self.pk).count()
@ -2895,7 +2895,7 @@ class BomItem(models.Model, DataImportMixin):
# If the sub_part is 'trackable' then the 'quantity' field must be an integer # If the sub_part is 'trackable' then the 'quantity' field must be an integer
if self.sub_part.trackable: if self.sub_part.trackable:
if not self.quantity == int(self.quantity): if self.quantity != int(self.quantity):
raise ValidationError({ raise ValidationError({
"quantity": _("Quantity must be integer value for trackable parts") "quantity": _("Quantity must be integer value for trackable parts")
}) })

View File

@ -21,6 +21,85 @@ import build.models
import order.models import order.models
class PartCategoryAPITest(InvenTreeAPITestCase):
"""Unit tests for the PartCategory API"""
fixtures = [
'category',
'part',
'location',
'bom',
'company',
'test_templates',
'manufacturer_part',
'supplier_part',
'order',
'stock',
]
roles = [
'part.change',
'part.add',
'part.delete',
'part_category.change',
'part_category.add',
]
def test_category_list(self):
# List all part categories
url = reverse('api-part-category-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 8)
# Filter by parent, depth=1
response = self.get(
url,
{
'parent': 1,
'cascade': False,
},
expected_code=200
)
self.assertEqual(len(response.data), 3)
# Filter by parent, cascading
response = self.get(
url,
{
'parent': 1,
'cascade': True,
},
expected_code=200,
)
self.assertEqual(len(response.data), 5)
def test_category_metadata(self):
"""Test metadata endpoint for the PartCategory"""
cat = PartCategory.objects.get(pk=1)
cat.metadata = {
'foo': 'bar',
'water': 'melon',
'abc': 'xyz',
}
cat.set_metadata('abc', 'ABC')
response = self.get(reverse('api-part-category-metadata', kwargs={'pk': 1}), expected_code=200)
metadata = response.data['metadata']
self.assertEqual(metadata['foo'], 'bar')
self.assertEqual(metadata['water'], 'melon')
self.assertEqual(metadata['abc'], 'ABC')
class PartOptionsAPITest(InvenTreeAPITestCase): class PartOptionsAPITest(InvenTreeAPITestCase):
""" """
Tests for the various OPTIONS endpoints in the /part/ API Tests for the various OPTIONS endpoints in the /part/ API
@ -1021,6 +1100,59 @@ class PartDetailTests(InvenTreeAPITestCase):
self.assertEqual(data['in_stock'], 9000) self.assertEqual(data['in_stock'], 9000)
self.assertEqual(data['unallocated_stock'], 9000) self.assertEqual(data['unallocated_stock'], 9000)
def test_part_metadata(self):
"""
Tests for the part metadata endpoint
"""
url = reverse('api-part-metadata', kwargs={'pk': 1})
part = Part.objects.get(pk=1)
# Metadata is initially null
self.assertIsNone(part.metadata)
part.metadata = {'foo': 'bar'}
part.save()
response = self.get(url, expected_code=200)
self.assertEqual(response.data['metadata']['foo'], 'bar')
# Add more data via the API
# Using the 'patch' method causes the new data to be merged in
self.patch(
url,
{
'metadata': {
'hello': 'world',
}
},
expected_code=200
)
part.refresh_from_db()
self.assertEqual(part.metadata['foo'], 'bar')
self.assertEqual(part.metadata['hello'], 'world')
# Now, issue a PUT request (existing data will be replacted)
self.put(
url,
{
'metadata': {
'x': 'y'
},
},
expected_code=200
)
part.refresh_from_db()
self.assertFalse('foo' in part.metadata)
self.assertFalse('hello' in part.metadata)
self.assertEqual(part.metadata['x'], 'y')
class PartAPIAggregationTest(InvenTreeAPITestCase): class PartAPIAggregationTest(InvenTreeAPITestCase):
""" """

View File

@ -199,7 +199,7 @@ class PartTest(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
part_2.validate_unique() part_2.validate_unique()
def test_metadata(self): def test_attributes(self):
self.assertEqual(self.r1.name, 'R_2K2_0805') self.assertEqual(self.r1.name, 'R_2K2_0805')
self.assertEqual(self.r1.get_absolute_url(), '/part/3/') self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
@ -245,6 +245,24 @@ class PartTest(TestCase):
self.assertEqual(float(self.r1.get_internal_price(1)), 0.08) self.assertEqual(float(self.r1.get_internal_price(1)), 0.08)
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5) self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
def test_metadata(self):
"""Unit tests for the Part metadata field"""
p = Part.objects.get(pk=1)
self.assertIsNone(p.metadata)
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
# Test update via the set_metadata() method
p.set_metadata('test', 3)
self.assertEqual(p.get_metadata('test'), 3)
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)
class TestTemplateTest(TestCase): class TestTemplateTest(TestCase):

View File

@ -628,7 +628,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
self.response = response self.response = response
# Check for valid response code # Check for valid response code
if not response.status_code == 200: if response.status_code != 200:
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code)) form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
return return

View File

@ -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',
] ]

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,17 +5,19 @@ 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 filters, generics, permissions, status
from rest_framework import status
from rest_framework import permissions
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.response import Response 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.base.locate.api import LocatePluginView
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
@ -35,6 +37,35 @@ class PluginList(generics.ListAPIView):
serializer_class = PluginSerializers.PluginConfigSerializer serializer_class = PluginSerializers.PluginConfigSerializer
queryset = PluginConfig.objects.all() queryset = PluginConfig.objects.all()
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Filter plugins which support a given mixin
mixin = params.get('mixin', None)
if mixin:
matches = []
for result in queryset:
if mixin in result.mixins().keys():
matches.append(result.pk)
queryset = queryset.filter(pk__in=matches)
return queryset
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_fields = [
'active',
]
ordering_fields = [ ordering_fields = [
'key', 'key',
'name', 'name',
@ -141,7 +172,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 +189,12 @@ 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)),
re_path(r'^locate/', LocatePluginView.as_view(), name='api-locate-plugin'),
]
general_plugin_api_urls = [
# Plugin settings URLs # Plugin settings URLs
re_path(r'^settings/', include([ re_path(r'^settings/', include([
@ -174,3 +212,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))
)

View File

View File

View File

@ -0,0 +1,41 @@
"""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:
plugin.perform_action(request.user, data=data)
return Response(plugin.get_response(request.user, data=data))
# If we got to here, no matching action was found
return Response({
'error': _("No matching action found"),
"action": action,
})

View File

@ -24,25 +24,18 @@ class ActionMixin:
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
return self.name return self.name
def init(self, user, data=None): def perform_action(self, user=None, data=None):
"""
An action plugin takes a user reference, and an optional dataset (dict)
"""
self.user = user
self.data = data
def perform_action(self):
""" """
Override this method to perform the action! Override this method to perform the action!
""" """
def get_result(self): def get_result(self, user=None, data=None):
""" """
Result of the action? Result of the action?
""" """
@ -50,19 +43,19 @@ class ActionMixin:
# Re-implement this for cutsom actions # Re-implement this for cutsom actions
return False return False
def get_info(self): def get_info(self, user=None, data=None):
""" """
Extra info? Can be a string / dict / etc Extra info? Can be a string / dict / etc
""" """
return None return None
def get_response(self): def get_response(self, user=None, data=None):
""" """
Return a response. Default implementation is a simple response Return a response. Default implementation is a simple response
which can be overridden. which can be overridden.
""" """
return { return {
"action": self.action_name(), "action": self.action_name(),
"result": self.get_result(), "result": self.get_result(user, data),
"info": self.get_info(), "info": self.get_info(user, data),
} }

View File

@ -0,0 +1,94 @@
""" Unit tests for action plugins """
from django.test import TestCase
from django.contrib.auth import get_user_model
from plugin import InvenTreePlugin
from plugin.mixins import ActionMixin
class ActionMixinTests(TestCase):
""" Tests for ActionMixin """
ACTION_RETURN = 'a action was performed'
def setUp(self):
class SimplePlugin(ActionMixin, InvenTreePlugin):
pass
self.plugin = SimplePlugin()
class TestActionPlugin(ActionMixin, InvenTreePlugin):
"""a action plugin"""
ACTION_NAME = 'abc123'
def perform_action(self, user=None, data=None):
return ActionMixinTests.ACTION_RETURN + 'action'
def get_result(self, user=None, data=None):
return ActionMixinTests.ACTION_RETURN + 'result'
def get_info(self, user=None, data=None):
return ActionMixinTests.ACTION_RETURN + 'info'
self.action_plugin = TestActionPlugin()
class NameActionPlugin(ActionMixin, InvenTreePlugin):
NAME = 'Aplugin'
self.action_name = NameActionPlugin()
def test_action_name(self):
"""check the name definition possibilities"""
self.assertEqual(self.plugin.action_name(), '')
self.assertEqual(self.action_plugin.action_name(), 'abc123')
self.assertEqual(self.action_name.action_name(), 'Aplugin')
def test_function(self):
"""check functions"""
# the class itself
self.assertIsNone(self.plugin.perform_action())
self.assertEqual(self.plugin.get_result(), False)
self.assertIsNone(self.plugin.get_info())
self.assertEqual(self.plugin.get_response(), {
"action": '',
"result": False,
"info": None,
})
# overriden functions
self.assertEqual(self.action_plugin.perform_action(), self.ACTION_RETURN + 'action')
self.assertEqual(self.action_plugin.get_result(), self.ACTION_RETURN + 'result')
self.assertEqual(self.action_plugin.get_info(), self.ACTION_RETURN + 'info')
self.assertEqual(self.action_plugin.get_response(), {
"action": 'abc123',
"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 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
@ -63,7 +63,6 @@ class BarcodeScan(APIView):
plugin = None plugin = None
for current_plugin in plugins: for current_plugin in plugins:
# TODO @matmair make simpler after InvenTree 0.7.0 release
current_plugin.init(barcode_data) current_plugin.init(barcode_data)
if current_plugin.validate(): if current_plugin.validate():
@ -168,7 +167,6 @@ class BarcodeAssign(APIView):
plugin = None plugin = None
for current_plugin in plugins: for current_plugin in plugins:
# TODO @matmair make simpler after InvenTree 0.7.0 release
current_plugin.init(barcode_data) current_plugin.init(barcode_data)
if current_plugin.validate(): if current_plugin.validate():
@ -237,7 +235,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'

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 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

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.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')

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)
raise MixinNotImplementedError('This Plugin must implement a `print_label` method')

View File

@ -0,0 +1,82 @@
"""API for location plugins"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from rest_framework import permissions
from rest_framework.exceptions import ParseError, NotFound
from rest_framework.response import Response
from rest_framework.views import APIView
from InvenTree.tasks import offload_task
from plugin import registry
from stock.models import StockItem, StockLocation
class LocatePluginView(APIView):
"""
Endpoint for using a custom plugin to identify or 'locate' a stock item or location
"""
permission_classes = [
permissions.IsAuthenticated,
]
def post(self, request, *args, **kwargs):
# Which plugin to we wish to use?
plugin = request.data.get('plugin', None)
if not plugin:
raise ParseError("'plugin' field must be supplied")
# Check that the plugin exists, and supports the 'locate' mixin
plugins = registry.with_mixin('locate')
if plugin not in [p.slug for p in plugins]:
raise ParseError(f"Plugin '{plugin}' is not installed, or does not support the location mixin")
# StockItem to identify
item_pk = request.data.get('item', None)
# StockLocation to identify
location_pk = request.data.get('location', None)
if not item_pk and not location_pk:
raise ParseError("Must supply either 'item' or 'location' parameter")
data = {
"success": "Identification plugin activated",
"plugin": plugin,
}
# StockItem takes priority
if item_pk:
try:
StockItem.objects.get(pk=item_pk)
offload_task('plugin.registry.call_function', plugin, 'locate_stock_item', item_pk)
data['item'] = item_pk
return Response(data)
except StockItem.DoesNotExist:
raise NotFound("StockItem matching PK '{item}' not found")
elif location_pk:
try:
StockLocation.objects.get(pk=location_pk)
offload_task('plugin.registry.call_function', plugin, 'locate_stock_location', location_pk)
data['location'] = location_pk
return Response(data)
except StockLocation.DoesNotExist:
raise NotFound("StockLocation matching PK {'location'} not found")
else:
raise NotFound()

View File

@ -0,0 +1,74 @@
"""Plugin mixin for locating stock items and locations"""
import logging
from plugin.helpers import MixinImplementationError
logger = logging.getLogger('inventree')
class LocateMixin:
"""
Mixin class which provides support for 'locating' inventory items,
for example identifying the location of a particular StockLocation.
Plugins could implement audible or visual cues to direct attention to the location,
with (for e.g.) LED strips or buzzers, or some other method.
The plugins may also be used to *deliver* a particular stock item to the user.
A class which implements this mixin may implement the following methods:
- locate_stock_item : Used to locate / identify a particular stock item
- locate_stock_location : Used to locate / identify a particular stock location
Refer to the default method implementations below for more information!
"""
class MixinMeta:
MIXIN_NAME = "Locate"
def __init__(self):
super().__init__()
self.add_mixin('locate', True, __class__)
def locate_stock_item(self, item_pk):
"""
Attempt to locate a particular StockItem
Arguments:
item_pk: The PK (primary key) of the StockItem to be located
The default implementation for locating a StockItem
attempts to locate the StockLocation where the item is located.
An attempt is only made if the StockItem is *in stock*
Note: A custom implemenation could always change this behaviour
"""
logger.info(f"LocateMixin: Attempting to locate StockItem pk={item_pk}")
from stock.models import StockItem
try:
item = StockItem.objects.get(pk=item_pk)
if item.in_stock and item.location is not None:
self.locate_stock_location(item.location.pk)
except StockItem.DoesNotExist:
logger.warning("LocateMixin: StockItem pk={item_pk} not found")
pass
def locate_stock_location(self, location_pk):
"""
Attempt to location a particular StockLocation
Arguments:
location_pk: The PK (primary key) of the StockLocation to be located
Note: The default implementation here does nothing!
"""
raise MixinImplementationError

View File

@ -1,25 +1,26 @@
# -*- 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, user=None, data=None):
print("Action plugin in action!") print("Action plugin in action!")
def get_info(self): def get_info(self, user, data=None):
return { return {
"user": self.user.username, "user": user.username,
"hello": "world", "hello": "world",
} }
def get_result(self): def get_result(self, user=None, data=None):
return True return True

View File

@ -15,7 +15,7 @@ class SimpleActionPluginTests(TestCase):
self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password') self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password') self.client.login(username='testuser', password='password')
self.plugin = SimpleActionPlugin(user=self.test_user) self.plugin = SimpleActionPlugin()
def test_name(self): def test_name(self):
"""check plugn names """ """check plugn names """

View File

@ -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):
""" """

View File

@ -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')

View File

@ -1,239 +1,11 @@
""" """
Functions for triggering and responding to server side events Import helper for events
""" """
# -*- coding: utf-8 -*- from plugin.base.event.events import process_event, register_event, trigger_event
from __future__ import unicode_literals
import logging __all__ = [
'process_event',
from django.utils.translation import gettext_lazy as _ 'register_event',
'trigger_event',
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]
)

View File

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

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,15 @@
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
from ..base.locate.mixins import LocateMixin
__all__ = [ __all__ = [
'APICallMixin', 'APICallMixin',
@ -21,6 +24,7 @@ __all__ = [
'PanelMixin', 'PanelMixin',
'ActionMixin', 'ActionMixin',
'BarcodeMixin', 'BarcodeMixin',
'LocateMixin',
'SingleNotificationMethod', 'SingleNotificationMethod',
'BulkNotificationMethod', 'BulkNotificationMethod',
] ]

View File

@ -4,14 +4,75 @@ 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 MetadataMixin(models.Model):
"""
Model mixin class which adds a JSON metadata field to a model,
for use by any (and all) plugins.
The intent of this mixin is to provide a metadata field on a model instance,
for plugins to read / modify as required, to store any extra information.
The assumptions for models implementing this mixin are:
- The internal InvenTree business logic will make no use of this field
- Multiple plugins may read / write to this metadata field, and not assume they have sole rights
"""
class Meta:
abstract = True
metadata = models.JSONField(
blank=True, null=True,
verbose_name=_('Plugin Metadata'),
help_text=_('JSON metadata field, for use by external plugins'),
)
def get_metadata(self, key: str, backup_value=None):
"""
Finds metadata for this model instance, using the provided key for lookup
Args:
key: String key for requesting metadata. e.g. if a plugin is accessing the metadata, the plugin slug should be used
Returns:
Python dict object containing requested metadata. If no matching metadata is found, returns None
"""
if self.metadata is None:
return backup_value
return self.metadata.get(key, backup_value)
def set_metadata(self, key: str, data, commit=True):
"""
Save the provided metadata under the provided key.
Args:
key: String key for saving metadata
data: Data object to save - must be able to be rendered as a JSON string
overwrite: If true, existing metadata with the provided key will be overwritten. If false, a merge will be attempted
"""
if self.metadata is None:
# Handle a null field value
self.metadata = {}
self.metadata[key] = data
if commit:
self.save()
class PluginConfig(models.Model): class PluginConfig(models.Model):
@ -59,7 +120,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 +158,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 +204,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, {})

View File

@ -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)

View File

@ -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)
@ -140,7 +133,6 @@ class PluginsRegistry:
if retry_counter <= 0: # pragma: no cover if retry_counter <= 0: # pragma: no cover
if settings.PLUGIN_TESTING: if settings.PLUGIN_TESTING:
print('[PLUGIN] Max retries, breaking loading') print('[PLUGIN] Max retries, breaking loading')
# TODO error for server status
break break
if settings.PLUGIN_TESTING: if settings.PLUGIN_TESTING:
print(f'[PLUGIN] Above error occured during testing - {retry_counter}/{settings.PLUGIN_RETRY} retries left') print(f'[PLUGIN] Above error occured during testing - {retry_counter}/{settings.PLUGIN_RETRY} retries left')
@ -198,9 +190,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 +200,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 +226,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 +238,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 +271,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)
@ -309,7 +300,6 @@ class PluginsRegistry:
# Errors are bad so disable the plugin in the database # Errors are bad so disable the plugin in the database
if not settings.PLUGIN_TESTING: # pragma: no cover if not settings.PLUGIN_TESTING: # pragma: no cover
plugin_db_setting.active = False plugin_db_setting.active = False
# TODO save the error to the plugin
plugin_db_setting.save(no_reload=True) plugin_db_setting.save(no_reload=True)
# Add to inactive plugins so it shows up in the ui # Add to inactive plugins so it shows up in the ui
@ -318,9 +308,7 @@ class PluginsRegistry:
# Initialize package # Initialize package
# 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 logger.info(f'Loading plugin {plug_name}')
# but we could enhance those to check signatures, run the plugin against a whitelist etc.
logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
try: try:
plugin = plugin() plugin = plugin()
@ -328,7 +316,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 +331,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 +340,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 +364,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 +375,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 +419,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 +508,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:

View 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 """

Some files were not shown because too many files have changed in this diff Show More