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*
env:
python_version: 3.8
python_version: 3.9
node_version: 16
server_start_sleep: 60

View File

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

View File

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

View File

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

View File

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

View File

@ -142,6 +142,18 @@ class InvenTreeAPITestCase(APITestCase):
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):
"""
Issue an OPTIONS request

View File

@ -4,11 +4,16 @@ InvenTree API version information
# 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
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
- Adds "export to file" functionality for PurchaseOrder API endpoint
- Adds "export to file" functionality for SalesOrder API endpoint

View File

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

View File

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

View File

@ -250,7 +250,7 @@ class InvenTreeMetadata(SimpleMetadata):
field_info = super().get_field_info(field)
# 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()
# Force non-nullable fields to read as "required"

View File

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

View File

@ -259,7 +259,7 @@ class InvenTreeAttachment(models.Model):
new_file = os.path.abspath(new_file)
# 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}'")
raise ValidationError(_("Invalid attachment directory"))

View File

@ -658,7 +658,7 @@ AUTH_PASSWORD_VALIDATORS = [
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")
EXTRA_URL_SCHEMES = []

View File

@ -126,8 +126,8 @@ def heartbeat():
try:
from django_q.models import Success
logger.info("Could not perform heartbeat task - App registry not ready")
except AppRegistryNotReady: # pragma: no cover
logger.info("Could not perform heartbeat task - App registry not ready")
return
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')
if not response.status_code == 200:
if response.status_code != 200:
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
data = json.loads(response.text)
@ -216,13 +216,13 @@ def check_for_updates():
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")
return
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")
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')
self.assertEqual(response.status_code, 200)
def test_not_found(self):
"""Test that the NotFoundView is working"""
response = self.client.get('/api/anc')
self.assertEqual(response.status_code, 404)
class APITests(InvenTreeAPITestCase):
""" Tests for the InvenTree API """

View File

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

View File

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

View File

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

View File

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

View File

@ -777,7 +777,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
if not output.is_building:
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"))
# Unallocate all build items against the output
@ -1240,7 +1240,7 @@ class BuildItem(models.Model):
})
# 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({
'quantity': _('Quantity must be 1 for serialized stock')
})

View File

@ -199,7 +199,7 @@ class FileManager:
try:
# 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)
except ValueError:
pass

View File

@ -271,9 +271,9 @@ class BaseInvenTreeSetting(models.Model):
plugin = kwargs.get('plugin', None)
if plugin is not None:
from plugin import InvenTreePluginBase
from plugin import InvenTreePlugin
if issubclass(plugin.__class__, InvenTreePluginBase):
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
filters['plugin'] = plugin
@ -375,9 +375,9 @@ class BaseInvenTreeSetting(models.Model):
filters['user'] = user
if plugin is not None:
from plugin import InvenTreePluginBase
from plugin import InvenTreePlugin
if issubclass(plugin.__class__, InvenTreePluginBase):
if issubclass(plugin.__class__, InvenTreePlugin):
filters['plugin'] = plugin.plugin_config()
else:
filters['plugin'] = plugin
@ -1802,10 +1802,8 @@ class WebhookEndpoint(models.Model):
def process_webhook(self):
if self.token:
self.verify = VerificationMethod.TOKEN
# TODO make a object-setting
if self.secret:
self.verify = VerificationMethod.HMAC
# TODO make a object-setting
return True
def validate_token(self, payload, headers, request):

View File

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

View File

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

View File

@ -494,7 +494,7 @@ class SupplierPart(models.Model):
# Ensure that the linked manufacturer_part points to the same 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({
'manufacturer_part': _("Linked manufacturer part must reference the same base part"),
})

View File

@ -162,7 +162,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
self.response = response
# 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))
return

View File

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

View File

@ -105,7 +105,7 @@ class LabelConfig(AppConfig):
# File already exists - let's see if it is the "same",
# 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}'")
to_copy = True
@ -199,7 +199,7 @@ class LabelConfig(AppConfig):
# File already exists - let's see if it is the "same",
# 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}'")
to_copy = True
@ -291,7 +291,7 @@ class LabelConfig(AppConfig):
if os.path.exists(dst_file):
# 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}'")
to_copy = True

View File

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

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 users.models import Owner
from plugin.serializers import MetadataSerializer
class GeneralExtraLineList:
"""
@ -347,6 +349,15 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
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):
"""
API endpoint to receive stock items against a purchase order.
@ -916,6 +927,15 @@ class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
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):
"""
API endpoint to allocation stock items against a SalesOrder,
@ -1138,10 +1158,13 @@ order_api_urls = [
# Individual purchase order detail URLs
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'^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'),
])),
@ -1178,10 +1201,13 @@ order_api_urls = [
# Sales order detail view
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-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'),
])),

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 stock import models as stock_models
from company.models import Company, SupplierPart
from plugin.events import trigger_event
from plugin.models import MetadataMixin
import InvenTree.helpers
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
@ -97,7 +99,7 @@ def get_next_so_number():
return reference
class Order(ReferenceIndexingMixin):
class Order(MetadataMixin, ReferenceIndexingMixin):
""" Abstract model for an order.
Instances of this class:
@ -306,7 +308,7 @@ class PurchaseOrder(Order):
except ValueError:
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")})
if group:
@ -445,7 +447,7 @@ class PurchaseOrder(Order):
if barcode is None:
barcode = ''
if not self.status == PurchaseOrderStatus.PLACED:
if self.status != PurchaseOrderStatus.PLACED:
raise ValidationError(
"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
"""
if not self.status == SalesOrderStatus.PENDING:
if self.status != SalesOrderStatus.PENDING:
return False
return True
@ -1295,7 +1297,7 @@ class SalesOrderAllocation(models.Model):
raise ValidationError({'item': _('Stock item has not been assigned')})
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')
except PartModels.Part.DoesNotExist:
errors['line'] = _('Cannot allocate stock to a line without a part')
@ -1310,7 +1312,7 @@ class SalesOrderAllocation(models.Model):
if self.quantity <= 0:
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')
if self.line.order != self.shipment.order:

View File

@ -306,6 +306,22 @@ class PurchaseOrderTest(OrderTest):
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):
"""
@ -875,6 +891,22 @@ class SalesOrderTest(OrderTest):
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):
"""

View File

@ -44,6 +44,7 @@ from stock.models import StockItem, StockLocation
from common.models import InvenTreeSetting
from build.models import Build, BuildItem
import order.models
from plugin.serializers import MetadataSerializer
from . import serializers as part_serializers
@ -203,6 +204,15 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
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):
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@ -587,6 +597,17 @@ class PartScheduling(generics.RetrieveAPIView):
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):
"""
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'^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'),
])),
@ -1973,6 +2002,9 @@ part_api_urls = [
# Endpoint for validating a BOM for the specific Part
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
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 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.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
import common.models
from build import models as BuildModels
from order import models as OrderModels
from company.models import SupplierPart
import part.settings as part_settings
from stock import models as StockModels
import common.models
import part.settings as part_settings
from plugin.models import MetadataMixin
logger = logging.getLogger("inventree")
class PartCategory(InvenTreeTree):
class PartCategory(MetadataMixin, InvenTreeTree):
""" PartCategory provides hierarchical organization of Part objects.
Attributes:
@ -327,7 +327,7 @@ class PartManager(TreeManager):
@cleanup.ignore
class Part(MPTTModel):
class Part(MetadataMixin, MPTTModel):
""" 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.
@ -444,7 +444,7 @@ class Part(MPTTModel):
previous = Part.objects.get(pk=self.pk)
# 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?
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 self.sub_part.trackable:
if not self.quantity == int(self.quantity):
if self.quantity != int(self.quantity):
raise ValidationError({
"quantity": _("Quantity must be integer value for trackable parts")
})

View File

@ -21,6 +21,85 @@ import build.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):
"""
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['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):
"""

View File

@ -199,7 +199,7 @@ class PartTest(TestCase):
with self.assertRaises(ValidationError):
part_2.validate_unique()
def test_metadata(self):
def test_attributes(self):
self.assertEqual(self.r1.name, 'R_2K2_0805')
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(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):

View File

@ -628,7 +628,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
self.response = response
# 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))
return

View File

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

View File

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

View File

@ -5,17 +5,19 @@ JSON API for the plugin app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.urls import include, re_path
from rest_framework import generics
from rest_framework import status
from rest_framework import permissions
from rest_framework import filters, generics, permissions, status
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from common.api import GlobalSettingsPermissions
from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.action.api import ActionPluginView
from plugin.base.locate.api import LocatePluginView
from plugin.models import PluginConfig, PluginSetting
import plugin.serializers as PluginSerializers
from plugin.registry import registry
@ -35,6 +37,35 @@ class PluginList(generics.ListAPIView):
serializer_class = PluginSerializers.PluginConfigSerializer
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 = [
'key',
'name',
@ -141,7 +172,8 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
plugin = registry.get_plugin(plugin_slug)
if plugin is None:
raise NotFound(detail=f"Plugin '{plugin_slug}' not found")
# This only occurs if the plugin mechanism broke
raise NotFound(detail=f"Plugin '{plugin_slug}' not found") # pragma: no cover
settings = getattr(plugin, 'SETTINGS', {})
@ -157,6 +189,12 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
plugin_api_urls = [
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
re_path(r'^barcode/', include(barcode_api_urls)),
re_path(r'^locate/', LocatePluginView.as_view(), name='api-locate-plugin'),
]
general_plugin_api_urls = [
# Plugin settings URLs
re_path(r'^settings/', include([
@ -174,3 +212,8 @@ plugin_api_urls = [
# Anything else
re_path(r'^.*$', PluginList.as_view(), name='api-plugin-list'),
]
if settings.PLUGINS_ENABLED:
plugin_api_urls.append(
re_path(r'^plugin/', include(general_plugin_api_urls))
)

View File

View File

View File

@ -0,0 +1,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.
If the ACTION_NAME parameter is empty,
uses the PLUGIN_NAME instead.
uses the NAME instead.
"""
if self.ACTION_NAME:
return self.ACTION_NAME
return self.name
def init(self, user, data=None):
"""
An action plugin takes a user reference, and an optional dataset (dict)
"""
self.user = user
self.data = data
def perform_action(self):
def perform_action(self, user=None, data=None):
"""
Override this method to perform the action!
"""
def get_result(self):
def get_result(self, user=None, data=None):
"""
Result of the action?
"""
@ -50,19 +43,19 @@ class ActionMixin:
# Re-implement this for cutsom actions
return False
def get_info(self):
def get_info(self, user=None, data=None):
"""
Extra info? Can be a string / dict / etc
"""
return None
def get_response(self):
def get_response(self, user=None, data=None):
"""
Return a response. Default implementation is a simple response
which can be overridden.
"""
return {
"action": self.action_name(),
"result": self.get_result(),
"info": self.get_info(),
"result": self.get_result(user, data),
"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 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
@ -63,7 +63,6 @@ class BarcodeScan(APIView):
plugin = None
for current_plugin in plugins:
# TODO @matmair make simpler after InvenTree 0.7.0 release
current_plugin.init(barcode_data)
if current_plugin.validate():
@ -168,7 +167,6 @@ class BarcodeAssign(APIView):
plugin = None
for current_plugin in plugins:
# TODO @matmair make simpler after InvenTree 0.7.0 release
current_plugin.init(barcode_data)
if current_plugin.validate():
@ -237,7 +235,7 @@ class BarcodeAssign(APIView):
barcode_api_urls = [
# Link a barcode to a part
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
# Catch-all performs barcode 'scan'

View File

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

@ -0,0 +1,39 @@
"""Plugin mixin classes for label plugins"""
from plugin.helpers import MixinNotImplementedError
class LabelPrintingMixin:
"""
Mixin which enables direct printing of stock labels.
Each plugin must provide a NAME attribute, which is used to uniquely identify the printer.
The plugin must also implement the print_label() function
"""
class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'Label printing'
def __init__(self): # pragma: no cover
super().__init__()
self.add_mixin('labels', True, __class__)
def print_label(self, label, **kwargs):
"""
Callback to print a single label
Arguments:
label: A black-and-white pillow Image object
kwargs:
length: The length of the label (in mm)
width: The width of the label (in mm)
"""
# Unimplemented (to be implemented by the particular plugin class)
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 -*-
"""sample implementation for ActionPlugin"""
from plugin.action import ActionPlugin
"""sample implementation for ActionMixin"""
from plugin import InvenTreePlugin
from plugin.mixins import ActionMixin
class SimpleActionPlugin(ActionPlugin):
class SimpleActionPlugin(ActionMixin, InvenTreePlugin):
"""
An EXTREMELY simple action plugin which demonstrates
the capability of the ActionPlugin class
the capability of the ActionMixin class
"""
PLUGIN_NAME = "SimpleActionPlugin"
NAME = "SimpleActionPlugin"
ACTION_NAME = "simple"
def perform_action(self):
def perform_action(self, user=None, data=None):
print("Action plugin in action!")
def get_info(self):
def get_info(self, user, data=None):
return {
"user": self.user.username,
"user": user.username,
"hello": "world",
}
def get_result(self):
def get_result(self, user=None, data=None):
return True

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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