mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of github.com:inventree/InvenTree
This commit is contained in:
commit
691b3f7d8d
2
.github/workflows/qc_checks.yaml
vendored
2
.github/workflows/qc_checks.yaml
vendored
@ -12,7 +12,7 @@ on:
|
||||
- l10*
|
||||
|
||||
env:
|
||||
python_version: 3.8
|
||||
python_version: 3.9
|
||||
node_version: 16
|
||||
|
||||
server_start_sleep: 60
|
||||
|
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
|
4
.github/workflows/translations.yml
vendored
4
.github/workflows/translations.yml
vendored
@ -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
|
||||
|
9
.github/workflows/welcome.yml
vendored
9
.github/workflows/welcome.yml
vendored
@ -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.
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
)
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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"))
|
||||
|
||||
|
@ -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 = []
|
||||
|
||||
|
@ -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}'")
|
||||
|
@ -96,6 +96,12 @@ class HTMLAPITests(TestCase):
|
||||
response = self.client.get(url, HTTP_ACCEPT='text/html')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_not_found(self):
|
||||
"""Test that the NotFoundView is working"""
|
||||
|
||||
response = self.client.get('/api/anc')
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class APITests(InvenTreeAPITestCase):
|
||||
""" Tests for the InvenTree API """
|
||||
|
66
InvenTree/InvenTree/test_middleware.py
Normal file
66
InvenTree/InvenTree/test_middleware.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Tests for middleware functions"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class MiddlewareTests(TestCase):
|
||||
"""Test for middleware functions"""
|
||||
|
||||
def check_path(self, url, code=200, **kwargs):
|
||||
response = self.client.get(url, HTTP_ACCEPT='application/json', **kwargs)
|
||||
self.assertEqual(response.status_code, code)
|
||||
return response
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create a user
|
||||
user = get_user_model()
|
||||
|
||||
self.user = user.objects.create_user(username='username', email='user@email.com', password='password')
|
||||
self.client.login(username='username', password='password')
|
||||
|
||||
def test_AuthRequiredMiddleware(self):
|
||||
"""Test the auth middleware"""
|
||||
|
||||
# test that /api/ routes go through
|
||||
self.check_path(reverse('api-inventree-info'))
|
||||
|
||||
# logout
|
||||
self.client.logout()
|
||||
|
||||
# check that account things go through
|
||||
self.check_path(reverse('account_login'))
|
||||
|
||||
# logout goes diretly to login
|
||||
self.check_path(reverse('account_logout'))
|
||||
|
||||
# check that frontend code is redirected to login
|
||||
response = self.check_path(reverse('stats'), 302)
|
||||
self.assertEqual(response.url, '/accounts/login/?next=/stats/')
|
||||
|
||||
# check that a 401 is raised
|
||||
self.check_path(reverse('settings.js'), 401)
|
||||
|
||||
def test_token_auth(self):
|
||||
"""Test auth with token auth"""
|
||||
# get token
|
||||
response = self.client.get(reverse('api-token'), format='json', data={})
|
||||
token = response.data['token']
|
||||
|
||||
# logout
|
||||
self.client.logout()
|
||||
# this should raise a 401
|
||||
self.check_path(reverse('settings.js'), 401)
|
||||
|
||||
# request with token
|
||||
self.check_path(reverse('settings.js'), HTTP_Authorization=f'Token {token}')
|
||||
|
||||
# Request with broken token
|
||||
self.check_path(reverse('settings.js'), 401, HTTP_Authorization='Token abcd123')
|
||||
|
||||
# should still fail without token
|
||||
self.check_path(reverse('settings.js'), 401)
|
@ -451,6 +451,11 @@ class TestSettings(TestCase):
|
||||
self.user_mdl = get_user_model()
|
||||
self.env = EnvironmentVarGuard()
|
||||
|
||||
# Create a user for auth
|
||||
user = get_user_model()
|
||||
self.user = user.objects.create_superuser('testuser1', 'test1@testing.com', 'password1')
|
||||
self.client.login(username='testuser1', password='password1')
|
||||
|
||||
def run_reload(self):
|
||||
from plugin import registry
|
||||
|
||||
@ -467,23 +472,49 @@ class TestSettings(TestCase):
|
||||
|
||||
# nothing set
|
||||
self.run_reload()
|
||||
self.assertEqual(user_count(), 0)
|
||||
self.assertEqual(user_count(), 1)
|
||||
|
||||
# not enough set
|
||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
||||
self.run_reload()
|
||||
self.assertEqual(user_count(), 0)
|
||||
self.assertEqual(user_count(), 1)
|
||||
|
||||
# enough set
|
||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
||||
self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email
|
||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password
|
||||
self.run_reload()
|
||||
self.assertEqual(user_count(), 1)
|
||||
self.assertEqual(user_count(), 2)
|
||||
|
||||
# create user manually
|
||||
self.user_mdl.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
self.assertEqual(user_count(), 3)
|
||||
# check it will not be created again
|
||||
self.env.set('INVENTREE_ADMIN_USER', 'testuser')
|
||||
self.env.set('INVENTREE_ADMIN_EMAIL', 'test@testing.com')
|
||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password')
|
||||
self.run_reload()
|
||||
self.assertEqual(user_count(), 3)
|
||||
|
||||
# make sure to clean up
|
||||
settings.TESTING_ENV = False
|
||||
|
||||
def test_initial_install(self):
|
||||
"""Test if install of plugins on startup works"""
|
||||
from plugin import registry
|
||||
|
||||
# Check an install run
|
||||
response = registry.install_plugin_file()
|
||||
self.assertEqual(response, 'first_run')
|
||||
|
||||
# Set dynamic setting to True and rerun to launch install
|
||||
InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user)
|
||||
registry.reload_plugins()
|
||||
|
||||
# Check that there was anotehr run
|
||||
response = registry.install_plugin_file()
|
||||
self.assertEqual(response, True)
|
||||
|
||||
def test_helpers_cfg_file(self):
|
||||
# normal run - not configured
|
||||
self.assertIn('InvenTree/InvenTree/config.yaml', config.get_config_file())
|
||||
|
@ -27,7 +27,7 @@ from order.api import order_api_urls
|
||||
from label.api import label_api_urls
|
||||
from report.api import report_api_urls
|
||||
from plugin.api import plugin_api_urls
|
||||
from plugin.barcode import barcode_api_urls
|
||||
from users.api import user_urls
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
@ -45,20 +45,11 @@ from .views import DynamicJsView
|
||||
from .views import NotificationsView
|
||||
|
||||
from .api import InfoView, NotFoundView
|
||||
from .api import ActionPluginView
|
||||
|
||||
from users.api import user_urls
|
||||
|
||||
admin.site.site_header = "InvenTree Admin"
|
||||
|
||||
apipatterns = []
|
||||
|
||||
if settings.PLUGINS_ENABLED:
|
||||
apipatterns.append(
|
||||
re_path(r'^plugin/', include(plugin_api_urls))
|
||||
)
|
||||
|
||||
apipatterns += [
|
||||
apipatterns = [
|
||||
re_path(r'^settings/', include(settings_api_urls)),
|
||||
re_path(r'^part/', include(part_api_urls)),
|
||||
re_path(r'^bom/', include(bom_api_urls)),
|
||||
@ -68,13 +59,10 @@ apipatterns += [
|
||||
re_path(r'^order/', include(order_api_urls)),
|
||||
re_path(r'^label/', include(label_api_urls)),
|
||||
re_path(r'^report/', include(report_api_urls)),
|
||||
|
||||
# User URLs
|
||||
re_path(r'^user/', include(user_urls)),
|
||||
|
||||
# Plugin endpoints
|
||||
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
|
||||
re_path(r'^barcode/', include(barcode_api_urls)),
|
||||
path('', include(plugin_api_urls)),
|
||||
|
||||
# Webhook enpoint
|
||||
path('', include(common_api_urls)),
|
||||
|
@ -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)
|
||||
|
@ -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')
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"""
|
||||
...
|
||||
|
@ -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"),
|
||||
})
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
@ -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'),
|
||||
])),
|
||||
|
||||
|
23
InvenTree/order/migrations/0067_auto_20220516_1120.py
Normal file
23
InvenTree/order/migrations/0067_auto_20220516_1120.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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:
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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'),
|
||||
])),
|
||||
|
23
InvenTree/part/migrations/0076_auto_20220516_0819.py
Normal file
23
InvenTree/part/migrations/0076_auto_20220516_0819.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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")
|
||||
})
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -3,17 +3,14 @@ Utility file to enable simper imports
|
||||
"""
|
||||
|
||||
from .registry import registry
|
||||
from .plugin import InvenTreePluginBase
|
||||
from .integration import IntegrationPluginBase
|
||||
from .action import ActionPlugin
|
||||
|
||||
from .plugin import InvenTreePlugin, IntegrationPluginBase
|
||||
from .helpers import MixinNotImplementedError, MixinImplementationError
|
||||
|
||||
__all__ = [
|
||||
'ActionPlugin',
|
||||
'IntegrationPluginBase',
|
||||
'InvenTreePluginBase',
|
||||
'registry',
|
||||
|
||||
'InvenTreePlugin',
|
||||
'IntegrationPluginBase',
|
||||
'MixinNotImplementedError',
|
||||
'MixinImplementationError',
|
||||
]
|
||||
|
@ -1,23 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Class for ActionPlugin"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
from plugin.builtin.action.mixins import ActionMixin
|
||||
import plugin.integration
|
||||
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
class ActionPlugin(ActionMixin, plugin.integration.IntegrationPluginBase):
|
||||
"""
|
||||
Legacy action definition - will be replaced
|
||||
Please use the new Integration Plugin API and the Action mixin
|
||||
"""
|
||||
# TODO @matmair remove this with InvenTree 0.7.0
|
||||
def __init__(self, user=None, data=None):
|
||||
warnings.warn("using the ActionPlugin is depreceated", DeprecationWarning)
|
||||
super().__init__()
|
||||
self.init(user, data)
|
@ -5,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))
|
||||
)
|
||||
|
0
InvenTree/plugin/base/__init__.py
Normal file
0
InvenTree/plugin/base/__init__.py
Normal file
0
InvenTree/plugin/base/action/__init__.py
Normal file
0
InvenTree/plugin/base/action/__init__.py
Normal file
41
InvenTree/plugin/base/action/api.py
Normal file
41
InvenTree/plugin/base/action/api.py
Normal 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,
|
||||
})
|
@ -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),
|
||||
}
|
94
InvenTree/plugin/base/action/test_action.py
Normal file
94
InvenTree/plugin/base/action/test_action.py
Normal 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'}
|
||||
)
|
0
InvenTree/plugin/base/barcodes/__init__.py
Normal file
0
InvenTree/plugin/base/barcodes/__init__.py
Normal file
@ -11,7 +11,7 @@ from stock.models import StockItem
|
||||
from stock.serializers import StockItemSerializer
|
||||
|
||||
from plugin.builtin.barcodes.inventree_barcode import InvenTreeBarcodePlugin
|
||||
from plugin.builtin.barcodes.mixins import hash_barcode
|
||||
from plugin.base.barcodes.mixins import hash_barcode
|
||||
from plugin import registry
|
||||
|
||||
|
||||
@ -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'
|
0
InvenTree/plugin/base/event/__init__.py
Normal file
0
InvenTree/plugin/base/event/__init__.py
Normal file
192
InvenTree/plugin/base/event/events.py
Normal file
192
InvenTree/plugin/base/event/events.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""
|
||||
Functions for triggering and responding to server side events
|
||||
"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch.dispatcher import receiver
|
||||
|
||||
from InvenTree.ready import canAppAccessDatabase, isImportingData
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
from plugin.registry import registry
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def trigger_event(event, *args, **kwargs):
|
||||
"""
|
||||
Trigger an event with optional arguments.
|
||||
|
||||
This event will be stored in the database,
|
||||
and the worker will respond to it later on.
|
||||
"""
|
||||
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
# Do nothing if plugins are not enabled
|
||||
return
|
||||
|
||||
if not canAppAccessDatabase():
|
||||
logger.debug(f"Ignoring triggered event '{event}' - database not ready")
|
||||
return
|
||||
|
||||
logger.debug(f"Event triggered: '{event}'")
|
||||
|
||||
offload_task(
|
||||
'plugin.events.register_event',
|
||||
event,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def register_event(event, *args, **kwargs):
|
||||
"""
|
||||
Register the event with any interested plugins.
|
||||
|
||||
Note: This function is processed by the background worker,
|
||||
as it performs multiple database access operations.
|
||||
"""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
logger.debug(f"Registering triggered event: '{event}'")
|
||||
|
||||
# Determine if there are any plugins which are interested in responding
|
||||
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'):
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
for slug, plugin in registry.plugins.items():
|
||||
|
||||
if plugin.mixin_enabled('events'):
|
||||
|
||||
config = plugin.plugin_config()
|
||||
|
||||
if config and config.active:
|
||||
|
||||
logger.debug(f"Registering callback for plugin '{slug}'")
|
||||
|
||||
# Offload a separate task for each plugin
|
||||
offload_task(
|
||||
'plugin.events.process_event',
|
||||
slug,
|
||||
event,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def process_event(plugin_slug, event, *args, **kwargs):
|
||||
"""
|
||||
Respond to a triggered event.
|
||||
|
||||
This function is run by the background worker process.
|
||||
|
||||
This function may queue multiple functions to be handled by the background worker.
|
||||
"""
|
||||
|
||||
logger.info(f"Plugin '{plugin_slug}' is processing triggered event '{event}'")
|
||||
|
||||
plugin = registry.plugins.get(plugin_slug, None)
|
||||
|
||||
if plugin is None:
|
||||
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
|
||||
return
|
||||
|
||||
plugin.process_event(event, *args, **kwargs)
|
||||
|
||||
|
||||
def allow_table_event(table_name):
|
||||
"""
|
||||
Determine if an automatic event should be fired for a given table.
|
||||
We *do not* want events to be fired for some tables!
|
||||
"""
|
||||
|
||||
if isImportingData():
|
||||
# Prevent table events during the data import process
|
||||
return False
|
||||
|
||||
table_name = table_name.lower().strip()
|
||||
|
||||
# Ignore any tables which start with these prefixes
|
||||
ignore_prefixes = [
|
||||
'account_',
|
||||
'auth_',
|
||||
'authtoken_',
|
||||
'django_',
|
||||
'error_',
|
||||
'exchange_',
|
||||
'otp_',
|
||||
'plugin_',
|
||||
'socialaccount_',
|
||||
'user_',
|
||||
'users_',
|
||||
]
|
||||
|
||||
if any([table_name.startswith(prefix) for prefix in ignore_prefixes]):
|
||||
return False
|
||||
|
||||
ignore_tables = [
|
||||
'common_notificationentry',
|
||||
'common_webhookendpoint',
|
||||
'common_webhookmessage',
|
||||
]
|
||||
|
||||
if table_name in ignore_tables:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@receiver(post_save)
|
||||
def after_save(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Trigger an event whenever a database entry is saved
|
||||
"""
|
||||
|
||||
table = sender.objects.model._meta.db_table
|
||||
|
||||
instance_id = getattr(instance, 'id', None)
|
||||
|
||||
if instance_id is None:
|
||||
return
|
||||
|
||||
if not allow_table_event(table):
|
||||
return
|
||||
|
||||
if created:
|
||||
trigger_event(
|
||||
f'{table}.created',
|
||||
id=instance.id,
|
||||
model=sender.__name__,
|
||||
)
|
||||
else:
|
||||
trigger_event(
|
||||
f'{table}.saved',
|
||||
id=instance.id,
|
||||
model=sender.__name__,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete)
|
||||
def after_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Trigger an event whenever a database entry is deleted
|
||||
"""
|
||||
|
||||
table = sender.objects.model._meta.db_table
|
||||
|
||||
if not allow_table_event(table):
|
||||
return
|
||||
|
||||
trigger_event(
|
||||
f'{table}.deleted',
|
||||
model=sender.__name__,
|
||||
)
|
29
InvenTree/plugin/base/event/mixins.py
Normal file
29
InvenTree/plugin/base/event/mixins.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Plugin mixin class for events"""
|
||||
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
|
||||
|
||||
class EventMixin:
|
||||
"""
|
||||
Mixin that provides support for responding to triggered events.
|
||||
|
||||
Implementing classes must provide a "process_event" function:
|
||||
"""
|
||||
|
||||
def process_event(self, event, *args, **kwargs):
|
||||
"""
|
||||
Function to handle events
|
||||
Must be overridden by plugin
|
||||
"""
|
||||
# Default implementation does not do anything
|
||||
raise MixinNotImplementedError
|
||||
|
||||
class MixinMeta:
|
||||
"""
|
||||
Meta options for this mixin
|
||||
"""
|
||||
MIXIN_NAME = 'Events'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_mixin('events', True, __class__)
|
0
InvenTree/plugin/base/integration/__init__.py
Normal file
0
InvenTree/plugin/base/integration/__init__.py
Normal file
@ -11,9 +11,8 @@ from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
import InvenTree.helpers
|
||||
|
||||
from plugin.helpers import MixinImplementationError, MixinNotImplementedError
|
||||
from plugin.helpers import MixinImplementationError, MixinNotImplementedError, render_template
|
||||
from plugin.models import PluginConfig, PluginSetting
|
||||
from plugin.template import render_template
|
||||
from plugin.urls import PLUGIN_BASE
|
||||
|
||||
|
||||
@ -238,32 +237,6 @@ class ScheduleMixin:
|
||||
logger.warning("unregister_tasks failed, database not ready")
|
||||
|
||||
|
||||
class EventMixin:
|
||||
"""
|
||||
Mixin that provides support for responding to triggered events.
|
||||
|
||||
Implementing classes must provide a "process_event" function:
|
||||
"""
|
||||
|
||||
def process_event(self, event, *args, **kwargs):
|
||||
"""
|
||||
Function to handle events
|
||||
Must be overridden by plugin
|
||||
"""
|
||||
# Default implementation does not do anything
|
||||
raise MixinNotImplementedError
|
||||
|
||||
class MixinMeta:
|
||||
"""
|
||||
Meta options for this mixin
|
||||
"""
|
||||
MIXIN_NAME = 'Events'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_mixin('events', True, __class__)
|
||||
|
||||
|
||||
class UrlsMixin:
|
||||
"""
|
||||
Mixin that enables custom URLs for the plugin
|
||||
@ -396,42 +369,6 @@ class AppMixin:
|
||||
return True
|
||||
|
||||
|
||||
class LabelPrintingMixin:
|
||||
"""
|
||||
Mixin which enables direct printing of stock labels.
|
||||
|
||||
Each plugin must provide a PLUGIN_NAME attribute, which is used to uniquely identify the printer.
|
||||
|
||||
The plugin must also implement the print_label() function
|
||||
"""
|
||||
|
||||
class MixinMeta:
|
||||
"""
|
||||
Meta options for this mixin
|
||||
"""
|
||||
MIXIN_NAME = 'Label printing'
|
||||
|
||||
def __init__(self): # pragma: no cover
|
||||
super().__init__()
|
||||
self.add_mixin('labels', True, __class__)
|
||||
|
||||
def print_label(self, label, **kwargs):
|
||||
"""
|
||||
Callback to print a single label
|
||||
|
||||
Arguments:
|
||||
label: A black-and-white pillow Image object
|
||||
|
||||
kwargs:
|
||||
length: The length of the label (in mm)
|
||||
width: The width of the label (in mm)
|
||||
|
||||
"""
|
||||
|
||||
# Unimplemented (to be implemented by the particular plugin class)
|
||||
... # pragma: no cover
|
||||
|
||||
|
||||
class APICallMixin:
|
||||
"""
|
||||
Mixin that enables easier API calls for a plugin
|
||||
@ -447,15 +384,15 @@ class APICallMixin:
|
||||
|
||||
Example:
|
||||
```
|
||||
from plugin import IntegrationPluginBase
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import APICallMixin, SettingsMixin
|
||||
|
||||
|
||||
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, IntegrationPluginBase):
|
||||
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, InvenTreePlugin):
|
||||
'''
|
||||
A small api call sample
|
||||
'''
|
||||
PLUGIN_NAME = "Sample API Caller"
|
||||
NAME = "Sample API Caller"
|
||||
|
||||
SETTINGS = {
|
||||
'API_TOKEN': {
|
||||
@ -496,9 +433,9 @@ class APICallMixin:
|
||||
def has_api_call(self):
|
||||
"""Is the mixin ready to call external APIs?"""
|
||||
if not bool(self.API_URL_SETTING):
|
||||
raise ValueError("API_URL_SETTING must be defined")
|
||||
raise MixinNotImplementedError("API_URL_SETTING must be defined")
|
||||
if not bool(self.API_TOKEN_SETTING):
|
||||
raise ValueError("API_TOKEN_SETTING must be defined")
|
||||
raise MixinNotImplementedError("API_TOKEN_SETTING must be defined")
|
||||
return True
|
||||
|
||||
@property
|
@ -1,17 +1,14 @@
|
||||
""" Unit tests for integration plugins """
|
||||
""" Unit tests for base mixins for plugins """
|
||||
|
||||
from django.test import TestCase
|
||||
from django.conf import settings
|
||||
from django.urls import include, re_path
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from plugin import IntegrationPluginBase
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
|
||||
from plugin.urls import PLUGIN_BASE
|
||||
|
||||
from plugin.samples.integration.sample import SampleIntegrationPlugin
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
|
||||
|
||||
class BaseMixinDefinition:
|
||||
@ -30,11 +27,11 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
|
||||
TEST_SETTINGS = {'SETTING1': {'default': '123', }}
|
||||
|
||||
def setUp(self):
|
||||
class SettingsCls(SettingsMixin, IntegrationPluginBase):
|
||||
class SettingsCls(SettingsMixin, InvenTreePlugin):
|
||||
SETTINGS = self.TEST_SETTINGS
|
||||
self.mixin = SettingsCls()
|
||||
|
||||
class NoSettingsCls(SettingsMixin, IntegrationPluginBase):
|
||||
class NoSettingsCls(SettingsMixin, InvenTreePlugin):
|
||||
pass
|
||||
self.mixin_nothing = NoSettingsCls()
|
||||
|
||||
@ -65,13 +62,13 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
|
||||
MIXIN_ENABLE_CHECK = 'has_urls'
|
||||
|
||||
def setUp(self):
|
||||
class UrlsCls(UrlsMixin, IntegrationPluginBase):
|
||||
class UrlsCls(UrlsMixin, InvenTreePlugin):
|
||||
def test():
|
||||
return 'ccc'
|
||||
URLS = [re_path('testpath', test, name='test'), ]
|
||||
self.mixin = UrlsCls()
|
||||
|
||||
class NoUrlsCls(UrlsMixin, IntegrationPluginBase):
|
||||
class NoUrlsCls(UrlsMixin, InvenTreePlugin):
|
||||
pass
|
||||
self.mixin_nothing = NoUrlsCls()
|
||||
|
||||
@ -104,7 +101,7 @@ class AppMixinTest(BaseMixinDefinition, TestCase):
|
||||
MIXIN_ENABLE_CHECK = 'has_app'
|
||||
|
||||
def setUp(self):
|
||||
class TestCls(AppMixin, IntegrationPluginBase):
|
||||
class TestCls(AppMixin, InvenTreePlugin):
|
||||
pass
|
||||
self.mixin = TestCls()
|
||||
|
||||
@ -119,30 +116,32 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
|
||||
MIXIN_ENABLE_CHECK = 'has_naviation'
|
||||
|
||||
def setUp(self):
|
||||
class NavigationCls(NavigationMixin, IntegrationPluginBase):
|
||||
class NavigationCls(NavigationMixin, InvenTreePlugin):
|
||||
NAVIGATION = [
|
||||
{'name': 'aa', 'link': 'plugin:test:test_view'},
|
||||
]
|
||||
NAVIGATION_TAB_NAME = 'abcd1'
|
||||
self.mixin = NavigationCls()
|
||||
|
||||
class NothingNavigationCls(NavigationMixin, IntegrationPluginBase):
|
||||
class NothingNavigationCls(NavigationMixin, InvenTreePlugin):
|
||||
pass
|
||||
self.nothing_mixin = NothingNavigationCls()
|
||||
|
||||
def test_function(self):
|
||||
# check right configuration
|
||||
self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}, ])
|
||||
# check wrong links fails
|
||||
with self.assertRaises(NotImplementedError):
|
||||
class NavigationCls(NavigationMixin, IntegrationPluginBase):
|
||||
NAVIGATION = ['aa', 'aa']
|
||||
NavigationCls()
|
||||
|
||||
# navigation name
|
||||
self.assertEqual(self.mixin.navigation_name, 'abcd1')
|
||||
self.assertEqual(self.nothing_mixin.navigation_name, '')
|
||||
|
||||
def test_fail(self):
|
||||
# check wrong links fails
|
||||
with self.assertRaises(NotImplementedError):
|
||||
class NavigationCls(NavigationMixin, InvenTreePlugin):
|
||||
NAVIGATION = ['aa', 'aa']
|
||||
NavigationCls()
|
||||
|
||||
|
||||
class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
MIXIN_HUMAN_NAME = 'API calls'
|
||||
@ -150,8 +149,8 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
MIXIN_ENABLE_CHECK = 'has_api_call'
|
||||
|
||||
def setUp(self):
|
||||
class MixinCls(APICallMixin, SettingsMixin, IntegrationPluginBase):
|
||||
PLUGIN_NAME = "Sample API Caller"
|
||||
class MixinCls(APICallMixin, SettingsMixin, InvenTreePlugin):
|
||||
NAME = "Sample API Caller"
|
||||
|
||||
SETTINGS = {
|
||||
'API_TOKEN': {
|
||||
@ -167,22 +166,23 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
API_URL_SETTING = 'API_URL'
|
||||
API_TOKEN_SETTING = 'API_TOKEN'
|
||||
|
||||
def get_external_url(self):
|
||||
def get_external_url(self, simple: bool = True):
|
||||
'''
|
||||
returns data from the sample endpoint
|
||||
'''
|
||||
return self.api_call('api/users/2')
|
||||
return self.api_call('api/users/2', simple_response=simple)
|
||||
self.mixin = MixinCls()
|
||||
|
||||
class WrongCLS(APICallMixin, IntegrationPluginBase):
|
||||
class WrongCLS(APICallMixin, InvenTreePlugin):
|
||||
pass
|
||||
self.mixin_wrong = WrongCLS()
|
||||
|
||||
class WrongCLS2(APICallMixin, IntegrationPluginBase):
|
||||
class WrongCLS2(APICallMixin, InvenTreePlugin):
|
||||
API_URL_SETTING = 'test'
|
||||
self.mixin_wrong2 = WrongCLS2()
|
||||
|
||||
def test_function(self):
|
||||
def test_base_setup(self):
|
||||
"""Test that the base settings work"""
|
||||
# check init
|
||||
self.assertTrue(self.mixin.has_api_call)
|
||||
# api_url
|
||||
@ -192,6 +192,8 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
headers = self.mixin.api_headers
|
||||
self.assertEqual(headers, {'Bearer': '', 'Content-Type': 'application/json'})
|
||||
|
||||
def test_args(self):
|
||||
"""Test that building up args work"""
|
||||
# api_build_url_args
|
||||
# 1 arg
|
||||
result = self.mixin.api_build_url_args({'a': 'b'})
|
||||
@ -203,88 +205,42 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
result = self.mixin.api_build_url_args({'a': 'b', 'c': ['d', 'e', 'f', ]})
|
||||
self.assertEqual(result, '?a=b&c=d,e,f')
|
||||
|
||||
def test_api_call(self):
|
||||
"""Test that api calls work"""
|
||||
# api_call
|
||||
result = self.mixin.get_external_url()
|
||||
self.assertTrue(result)
|
||||
self.assertIn('data', result,)
|
||||
|
||||
# api_call without json conversion
|
||||
result = self.mixin.get_external_url(False)
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(result.reason, 'OK')
|
||||
|
||||
# api_call with full url
|
||||
result = self.mixin.api_call('https://reqres.in/api/users/2', endpoint_is_url=True)
|
||||
self.assertTrue(result)
|
||||
|
||||
# api_call with post and data
|
||||
result = self.mixin.api_call(
|
||||
'api/users/',
|
||||
data={"name": "morpheus", "job": "leader"},
|
||||
method='POST'
|
||||
)
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(result['name'], 'morpheus')
|
||||
|
||||
# api_call with filter
|
||||
result = self.mixin.api_call('api/users', url_args={'page': '2'})
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(result['page'], 2)
|
||||
|
||||
def test_function_errors(self):
|
||||
"""Test function errors"""
|
||||
# wrongly defined plugins should not load
|
||||
with self.assertRaises(ValueError):
|
||||
with self.assertRaises(MixinNotImplementedError):
|
||||
self.mixin_wrong.has_api_call()
|
||||
|
||||
# cover wrong token setting
|
||||
with self.assertRaises(ValueError):
|
||||
self.mixin_wrong.has_api_call()
|
||||
|
||||
|
||||
class IntegrationPluginBaseTests(TestCase):
|
||||
""" Tests for IntegrationPluginBase """
|
||||
|
||||
def setUp(self):
|
||||
self.plugin = IntegrationPluginBase()
|
||||
|
||||
class SimpeIntegrationPluginBase(IntegrationPluginBase):
|
||||
PLUGIN_NAME = 'SimplePlugin'
|
||||
|
||||
self.plugin_simple = SimpeIntegrationPluginBase()
|
||||
|
||||
class NameIntegrationPluginBase(IntegrationPluginBase):
|
||||
PLUGIN_NAME = 'Aplugin'
|
||||
PLUGIN_SLUG = 'a'
|
||||
PLUGIN_TITLE = 'a titel'
|
||||
PUBLISH_DATE = "1111-11-11"
|
||||
AUTHOR = 'AA BB'
|
||||
DESCRIPTION = 'A description'
|
||||
VERSION = '1.2.3a'
|
||||
WEBSITE = 'http://aa.bb/cc'
|
||||
LICENSE = 'MIT'
|
||||
|
||||
self.plugin_name = NameIntegrationPluginBase()
|
||||
self.plugin_sample = SampleIntegrationPlugin()
|
||||
|
||||
def test_action_name(self):
|
||||
"""check the name definition possibilities"""
|
||||
# plugin_name
|
||||
self.assertEqual(self.plugin.plugin_name(), '')
|
||||
self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin')
|
||||
self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin')
|
||||
|
||||
# is_sampe
|
||||
self.assertEqual(self.plugin.is_sample, False)
|
||||
self.assertEqual(self.plugin_sample.is_sample, True)
|
||||
|
||||
# slug
|
||||
self.assertEqual(self.plugin.slug, '')
|
||||
self.assertEqual(self.plugin_simple.slug, 'simpleplugin')
|
||||
self.assertEqual(self.plugin_name.slug, 'a')
|
||||
|
||||
# human_name
|
||||
self.assertEqual(self.plugin.human_name, '')
|
||||
self.assertEqual(self.plugin_simple.human_name, 'SimplePlugin')
|
||||
self.assertEqual(self.plugin_name.human_name, 'a titel')
|
||||
|
||||
# description
|
||||
self.assertEqual(self.plugin.description, '')
|
||||
self.assertEqual(self.plugin_simple.description, 'SimplePlugin')
|
||||
self.assertEqual(self.plugin_name.description, 'A description')
|
||||
|
||||
# author
|
||||
self.assertEqual(self.plugin_name.author, 'AA BB')
|
||||
|
||||
# pub_date
|
||||
self.assertEqual(self.plugin_name.pub_date, datetime(1111, 11, 11, 0, 0))
|
||||
|
||||
# version
|
||||
self.assertEqual(self.plugin.version, None)
|
||||
self.assertEqual(self.plugin_simple.version, None)
|
||||
self.assertEqual(self.plugin_name.version, '1.2.3a')
|
||||
|
||||
# website
|
||||
self.assertEqual(self.plugin.website, None)
|
||||
self.assertEqual(self.plugin_simple.website, None)
|
||||
self.assertEqual(self.plugin_name.website, 'http://aa.bb/cc')
|
||||
|
||||
# license
|
||||
self.assertEqual(self.plugin.license, None)
|
||||
self.assertEqual(self.plugin_simple.license, None)
|
||||
self.assertEqual(self.plugin_name.license, 'MIT')
|
||||
with self.assertRaises(MixinNotImplementedError):
|
||||
self.mixin_wrong2.has_api_call()
|
0
InvenTree/plugin/base/label/__init__.py
Normal file
0
InvenTree/plugin/base/label/__init__.py
Normal file
53
InvenTree/plugin/base/label/label.py
Normal file
53
InvenTree/plugin/base/label/label.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Functions to print a label to a mixin printer"""
|
||||
import logging
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from plugin.registry import registry
|
||||
import common.notifications
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def print_label(plugin_slug, label_image, label_instance=None, user=None):
|
||||
"""
|
||||
Print label with the provided plugin.
|
||||
|
||||
This task is nominally handled by the background worker.
|
||||
|
||||
If the printing fails (throws an exception) then the user is notified.
|
||||
|
||||
Arguments:
|
||||
plugin_slug: The unique slug (key) of the plugin
|
||||
label_image: A PIL.Image image object to be printed
|
||||
"""
|
||||
|
||||
logger.info(f"Plugin '{plugin_slug}' is printing a label")
|
||||
|
||||
plugin = registry.plugins.get(plugin_slug, None)
|
||||
|
||||
if plugin is None:
|
||||
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
|
||||
return
|
||||
|
||||
try:
|
||||
plugin.print_label(label_image, width=label_instance.width, height=label_instance.height)
|
||||
except Exception as e:
|
||||
# Plugin threw an error - notify the user who attempted to print
|
||||
|
||||
ctx = {
|
||||
'name': _('Label printing failed'),
|
||||
'message': str(e),
|
||||
}
|
||||
|
||||
logger.error(f"Label printing failed: Sending notification to user '{user}'")
|
||||
|
||||
# Throw an error against the plugin instance
|
||||
common.notifications.trigger_notifaction(
|
||||
plugin.plugin_config(),
|
||||
'label.printing_failed',
|
||||
targets=[user],
|
||||
context=ctx,
|
||||
delivery_methods=[common.notifications.UIMessageNotification]
|
||||
)
|
39
InvenTree/plugin/base/label/mixins.py
Normal file
39
InvenTree/plugin/base/label/mixins.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""Plugin mixin classes for label plugins"""
|
||||
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
|
||||
|
||||
class LabelPrintingMixin:
|
||||
"""
|
||||
Mixin which enables direct printing of stock labels.
|
||||
|
||||
Each plugin must provide a NAME attribute, which is used to uniquely identify the printer.
|
||||
|
||||
The plugin must also implement the print_label() function
|
||||
"""
|
||||
|
||||
class MixinMeta:
|
||||
"""
|
||||
Meta options for this mixin
|
||||
"""
|
||||
MIXIN_NAME = 'Label printing'
|
||||
|
||||
def __init__(self): # pragma: no cover
|
||||
super().__init__()
|
||||
self.add_mixin('labels', True, __class__)
|
||||
|
||||
def print_label(self, label, **kwargs):
|
||||
"""
|
||||
Callback to print a single label
|
||||
|
||||
Arguments:
|
||||
label: A black-and-white pillow Image object
|
||||
|
||||
kwargs:
|
||||
length: The length of the label (in mm)
|
||||
width: The width of the label (in mm)
|
||||
|
||||
"""
|
||||
|
||||
# Unimplemented (to be implemented by the particular plugin class)
|
||||
raise MixinNotImplementedError('This Plugin must implement a `print_label` method')
|
82
InvenTree/plugin/base/locate/api.py
Normal file
82
InvenTree/plugin/base/locate/api.py
Normal 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()
|
74
InvenTree/plugin/base/locate/mixins.py
Normal file
74
InvenTree/plugin/base/locate/mixins.py
Normal 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
|
@ -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
|
||||
|
@ -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 """
|
@ -13,7 +13,7 @@ references model objects actually exist in the database.
|
||||
|
||||
import json
|
||||
|
||||
from plugin import IntegrationPluginBase
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import BarcodeMixin
|
||||
|
||||
from stock.models import StockItem, StockLocation
|
||||
@ -22,9 +22,9 @@ from part.models import Part
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
||||
class InvenTreeBarcodePlugin(BarcodeMixin, IntegrationPluginBase):
|
||||
class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
|
||||
PLUGIN_NAME = "InvenTreeBarcode"
|
||||
NAME = "InvenTreeBarcode"
|
||||
|
||||
def validate(self):
|
||||
"""
|
||||
|
@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
|
||||
from plugin import IntegrationPluginBase
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import BulkNotificationMethod, SettingsMixin
|
||||
import InvenTree.tasks
|
||||
|
||||
@ -15,12 +15,12 @@ class PlgMixin:
|
||||
return CoreNotificationsPlugin
|
||||
|
||||
|
||||
class CoreNotificationsPlugin(SettingsMixin, IntegrationPluginBase):
|
||||
class CoreNotificationsPlugin(SettingsMixin, InvenTreePlugin):
|
||||
"""
|
||||
Core notification methods for InvenTree
|
||||
"""
|
||||
|
||||
PLUGIN_NAME = "CoreNotificationsPlugin"
|
||||
NAME = "CoreNotificationsPlugin"
|
||||
AUTHOR = _('InvenTree contributors')
|
||||
DESCRIPTION = _('Integrated outgoing notificaton methods')
|
||||
|
||||
|
@ -1,239 +1,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',
|
||||
]
|
||||
|
@ -8,12 +8,17 @@ import sysconfig
|
||||
import traceback
|
||||
import inspect
|
||||
import pkgutil
|
||||
import logging
|
||||
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
# region logging / errors
|
||||
class IntegrationPluginError(Exception):
|
||||
"""
|
||||
@ -200,7 +205,7 @@ def get_plugins(pkg, baseclass):
|
||||
Return a list of all modules under a given package.
|
||||
|
||||
- Modules must be a subclass of the provided 'baseclass'
|
||||
- Modules must have a non-empty PLUGIN_NAME parameter
|
||||
- Modules must have a non-empty NAME parameter
|
||||
"""
|
||||
|
||||
plugins = []
|
||||
@ -212,8 +217,32 @@ def get_plugins(pkg, baseclass):
|
||||
# Iterate through each class in the module
|
||||
for item in get_classes(mod):
|
||||
plugin = item[1]
|
||||
if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME:
|
||||
if issubclass(plugin, baseclass) and plugin.NAME:
|
||||
plugins.append(plugin)
|
||||
|
||||
return plugins
|
||||
# endregion
|
||||
|
||||
|
||||
# region templates
|
||||
def render_template(plugin, template_file, context=None):
|
||||
"""
|
||||
Locate and render a template file, available in the global template context.
|
||||
"""
|
||||
|
||||
try:
|
||||
tmp = template.loader.get_template(template_file)
|
||||
except template.TemplateDoesNotExist:
|
||||
logger.error(f"Plugin {plugin.slug} could not locate template '{template_file}'")
|
||||
|
||||
return f"""
|
||||
<div class='alert alert-block alert-danger'>
|
||||
Template file <em>{template_file}</em> does not exist.
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Render with the provided context
|
||||
html = tmp.render(context)
|
||||
|
||||
return html
|
||||
# endregion
|
||||
|
@ -1,261 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Class for IntegrationPluginBase and Mixin Base
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import inspect
|
||||
from datetime import datetime
|
||||
import pathlib
|
||||
|
||||
from django.urls.base import reverse
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import plugin.plugin as plugin_base
|
||||
from plugin.helpers import get_git_log, GitStatus
|
||||
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
class MixinBase:
|
||||
"""
|
||||
Base set of mixin functions and mechanisms
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mixinreg = {}
|
||||
self._mixins = {}
|
||||
|
||||
def add_mixin(self, key: str, fnc_enabled=True, cls=None):
|
||||
"""
|
||||
Add a mixin to the plugins registry
|
||||
"""
|
||||
|
||||
self._mixins[key] = fnc_enabled
|
||||
self.setup_mixin(key, cls=cls)
|
||||
|
||||
def setup_mixin(self, key, cls=None):
|
||||
"""
|
||||
Define mixin details for the current mixin -> provides meta details for all active mixins
|
||||
"""
|
||||
|
||||
# get human name
|
||||
human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
|
||||
|
||||
# register
|
||||
self._mixinreg[key] = {
|
||||
'key': key,
|
||||
'human_name': human_name,
|
||||
}
|
||||
|
||||
@property
|
||||
def registered_mixins(self, with_base: bool = False):
|
||||
"""
|
||||
Get all registered mixins for the plugin
|
||||
"""
|
||||
|
||||
mixins = getattr(self, '_mixinreg', None)
|
||||
if mixins:
|
||||
# filter out base
|
||||
if not with_base and 'base' in mixins:
|
||||
del mixins['base']
|
||||
# only return dict
|
||||
mixins = [a for a in mixins.values()]
|
||||
return mixins
|
||||
|
||||
|
||||
class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
||||
"""
|
||||
The IntegrationPluginBase class is used to integrate with 3rd party software
|
||||
"""
|
||||
|
||||
AUTHOR = None
|
||||
DESCRIPTION = None
|
||||
PUBLISH_DATE = None
|
||||
VERSION = None
|
||||
WEBSITE = None
|
||||
LICENSE = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_mixin('base')
|
||||
self.def_path = inspect.getfile(self.__class__)
|
||||
self.path = os.path.dirname(self.def_path)
|
||||
|
||||
self.define_package()
|
||||
|
||||
@property
|
||||
def _is_package(self):
|
||||
"""
|
||||
Is the plugin delivered as a package
|
||||
"""
|
||||
return getattr(self, 'is_package', False)
|
||||
|
||||
@property
|
||||
def is_sample(self):
|
||||
"""
|
||||
Is this plugin part of the samples?
|
||||
"""
|
||||
path = str(self.package_path)
|
||||
return path.startswith('plugin/samples/')
|
||||
|
||||
# region properties
|
||||
@property
|
||||
def slug(self):
|
||||
"""
|
||||
Slug of plugin
|
||||
"""
|
||||
return self.plugin_slug()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
Name of plugin
|
||||
"""
|
||||
return self.plugin_name()
|
||||
|
||||
@property
|
||||
def human_name(self):
|
||||
"""
|
||||
Human readable name of plugin
|
||||
"""
|
||||
return self.plugin_title()
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""
|
||||
Description of plugin
|
||||
"""
|
||||
description = getattr(self, 'DESCRIPTION', None)
|
||||
if not description:
|
||||
description = self.plugin_name()
|
||||
return description
|
||||
|
||||
@property
|
||||
def author(self):
|
||||
"""
|
||||
Author of plugin - either from plugin settings or git
|
||||
"""
|
||||
author = getattr(self, 'AUTHOR', None)
|
||||
if not author:
|
||||
author = self.package.get('author')
|
||||
if not author:
|
||||
author = _('No author found') # pragma: no cover
|
||||
return author
|
||||
|
||||
@property
|
||||
def pub_date(self):
|
||||
"""
|
||||
Publishing date of plugin - either from plugin settings or git
|
||||
"""
|
||||
pub_date = getattr(self, 'PUBLISH_DATE', None)
|
||||
if not pub_date:
|
||||
pub_date = self.package.get('date')
|
||||
else:
|
||||
pub_date = datetime.fromisoformat(str(pub_date))
|
||||
if not pub_date:
|
||||
pub_date = _('No date found') # pragma: no cover
|
||||
return pub_date
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
"""
|
||||
Version of plugin
|
||||
"""
|
||||
version = getattr(self, 'VERSION', None)
|
||||
return version
|
||||
|
||||
@property
|
||||
def website(self):
|
||||
"""
|
||||
Website of plugin - if set else None
|
||||
"""
|
||||
website = getattr(self, 'WEBSITE', None)
|
||||
return website
|
||||
|
||||
@property
|
||||
def license(self):
|
||||
"""
|
||||
License of plugin
|
||||
"""
|
||||
lic = getattr(self, 'LICENSE', None)
|
||||
return lic
|
||||
# endregion
|
||||
|
||||
@property
|
||||
def package_path(self):
|
||||
"""
|
||||
Path to the plugin
|
||||
"""
|
||||
if self._is_package:
|
||||
return self.__module__ # pragma: no cover
|
||||
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
|
||||
|
||||
@property
|
||||
def settings_url(self):
|
||||
"""
|
||||
URL to the settings panel for this plugin
|
||||
"""
|
||||
return f'{reverse("settings")}#select-plugin-{self.slug}'
|
||||
|
||||
# region mixins
|
||||
def mixin(self, key):
|
||||
"""
|
||||
Check if mixin is registered
|
||||
"""
|
||||
return key in self._mixins
|
||||
|
||||
def mixin_enabled(self, key):
|
||||
"""
|
||||
Check if mixin is registered, enabled and ready
|
||||
"""
|
||||
if self.mixin(key):
|
||||
fnc_name = self._mixins.get(key)
|
||||
|
||||
# Allow for simple case where the mixin is "always" ready
|
||||
if fnc_name is True:
|
||||
return True
|
||||
|
||||
return getattr(self, fnc_name, True)
|
||||
return False
|
||||
# endregion
|
||||
|
||||
# region package info
|
||||
def _get_package_commit(self):
|
||||
"""
|
||||
Get last git commit for the plugin
|
||||
"""
|
||||
return get_git_log(self.def_path)
|
||||
|
||||
def _get_package_metadata(self):
|
||||
"""
|
||||
Get package metadata for plugin
|
||||
"""
|
||||
return {} # pragma: no cover # TODO add usage for package metadata
|
||||
|
||||
def define_package(self):
|
||||
"""
|
||||
Add package info of the plugin into plugins context
|
||||
"""
|
||||
package = self._get_package_metadata() if self._is_package else self._get_package_commit()
|
||||
|
||||
# process date
|
||||
if package.get('date'):
|
||||
package['date'] = datetime.fromisoformat(package.get('date'))
|
||||
|
||||
# process sign state
|
||||
sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
|
||||
if sign_state.status == 0:
|
||||
self.sign_color = 'success' # pragma: no cover
|
||||
elif sign_state.status == 1:
|
||||
self.sign_color = 'warning'
|
||||
else:
|
||||
self.sign_color = 'danger' # pragma: no cover
|
||||
|
||||
# set variables
|
||||
self.package = package
|
||||
self.sign_state = sign_state
|
||||
# endregion
|
@ -2,12 +2,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',
|
||||
]
|
||||
|
@ -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, {})
|
||||
|
@ -2,33 +2,71 @@
|
||||
"""
|
||||
Base Class for InvenTree plugins
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import inspect
|
||||
from datetime import datetime
|
||||
import pathlib
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.urls.base import reverse
|
||||
|
||||
from plugin.helpers import get_git_log, GitStatus
|
||||
|
||||
|
||||
class InvenTreePluginBase():
|
||||
"""
|
||||
Base class for a plugin
|
||||
DO NOT USE THIS DIRECTLY, USE plugin.IntegrationPluginBase
|
||||
"""
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
class MetaBase:
|
||||
"""Base class for a plugins metadata"""
|
||||
|
||||
# Override the plugin name for each concrete plugin instance
|
||||
PLUGIN_NAME = ''
|
||||
NAME = ''
|
||||
SLUG = None
|
||||
TITLE = None
|
||||
|
||||
PLUGIN_SLUG = None
|
||||
def get_meta_value(self, key: str, old_key: str = None, __default=None):
|
||||
"""Reference a meta item with a key
|
||||
|
||||
PLUGIN_TITLE = None
|
||||
Args:
|
||||
key (str): key for the value
|
||||
old_key (str, optional): depreceated key - will throw warning
|
||||
__default (optional): Value if nothing with key can be found. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Value referenced with key, old_key or __default if set and not value found
|
||||
"""
|
||||
value = getattr(self, key, None)
|
||||
|
||||
# The key was not used
|
||||
if old_key and value is None:
|
||||
value = getattr(self, old_key, None)
|
||||
|
||||
# Sound of a warning if old_key worked
|
||||
if value:
|
||||
warnings.warn(f'Usage of {old_key} was depreciated in 0.7.0 in favour of {key}', DeprecationWarning)
|
||||
|
||||
# Use __default if still nothing set
|
||||
if (value is None) and __default:
|
||||
return __default
|
||||
return value
|
||||
|
||||
def plugin_name(self):
|
||||
"""
|
||||
Name of plugin
|
||||
"""
|
||||
return self.PLUGIN_NAME
|
||||
return self.get_meta_value('NAME', 'PLUGIN_NAME')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
Name of plugin
|
||||
"""
|
||||
return self.plugin_name()
|
||||
|
||||
def plugin_slug(self):
|
||||
"""
|
||||
@ -36,22 +74,35 @@ class InvenTreePluginBase():
|
||||
If not set plugin name slugified
|
||||
"""
|
||||
|
||||
slug = getattr(self, 'PLUGIN_SLUG', None)
|
||||
|
||||
if slug is None:
|
||||
slug = self.get_meta_value('SLUG', 'PLUGIN_SLUG', None)
|
||||
if not slug:
|
||||
slug = self.plugin_name()
|
||||
|
||||
return slugify(slug.lower())
|
||||
|
||||
@property
|
||||
def slug(self):
|
||||
"""
|
||||
Slug of plugin
|
||||
"""
|
||||
return self.plugin_slug()
|
||||
|
||||
def plugin_title(self):
|
||||
"""
|
||||
Title of plugin
|
||||
"""
|
||||
|
||||
if self.PLUGIN_TITLE:
|
||||
return self.PLUGIN_TITLE
|
||||
else:
|
||||
return self.plugin_name()
|
||||
title = self.get_meta_value('TITLE', 'PLUGIN_TITLE', None)
|
||||
if title:
|
||||
return title
|
||||
return self.plugin_name()
|
||||
|
||||
@property
|
||||
def human_name(self):
|
||||
"""
|
||||
Human readable name of plugin
|
||||
"""
|
||||
return self.plugin_title()
|
||||
|
||||
def plugin_config(self):
|
||||
"""
|
||||
@ -83,11 +134,230 @@ class InvenTreePluginBase():
|
||||
return False # pragma: no cover
|
||||
|
||||
|
||||
# TODO @matmair remove after InvenTree 0.7.0 release
|
||||
class InvenTreePlugin(InvenTreePluginBase):
|
||||
class MixinBase:
|
||||
"""
|
||||
This is here for leagcy reasons and will be removed in the next major release
|
||||
Base set of mixin functions and mechanisms
|
||||
"""
|
||||
def __init__(self): # pragma: no cover
|
||||
warnings.warn("Using the InvenTreePlugin is depreceated", DeprecationWarning)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._mixinreg = {}
|
||||
self._mixins = {}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def mixin(self, key):
|
||||
"""
|
||||
Check if mixin is registered
|
||||
"""
|
||||
return key in self._mixins
|
||||
|
||||
def mixin_enabled(self, key):
|
||||
"""
|
||||
Check if mixin is registered, enabled and ready
|
||||
"""
|
||||
if self.mixin(key):
|
||||
fnc_name = self._mixins.get(key)
|
||||
|
||||
# Allow for simple case where the mixin is "always" ready
|
||||
if fnc_name is True:
|
||||
return True
|
||||
|
||||
return getattr(self, fnc_name, True)
|
||||
return False
|
||||
|
||||
def add_mixin(self, key: str, fnc_enabled=True, cls=None):
|
||||
"""
|
||||
Add a mixin to the plugins registry
|
||||
"""
|
||||
|
||||
self._mixins[key] = fnc_enabled
|
||||
self.setup_mixin(key, cls=cls)
|
||||
|
||||
def setup_mixin(self, key, cls=None):
|
||||
"""
|
||||
Define mixin details for the current mixin -> provides meta details for all active mixins
|
||||
"""
|
||||
|
||||
# get human name
|
||||
human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
|
||||
|
||||
# register
|
||||
self._mixinreg[key] = {
|
||||
'key': key,
|
||||
'human_name': human_name,
|
||||
}
|
||||
|
||||
@property
|
||||
def registered_mixins(self, with_base: bool = False):
|
||||
"""
|
||||
Get all registered mixins for the plugin
|
||||
"""
|
||||
|
||||
mixins = getattr(self, '_mixinreg', None)
|
||||
if mixins:
|
||||
# filter out base
|
||||
if not with_base and 'base' in mixins:
|
||||
del mixins['base']
|
||||
# only return dict
|
||||
mixins = [a for a in mixins.values()]
|
||||
return mixins
|
||||
|
||||
|
||||
class InvenTreePlugin(MixinBase, MetaBase):
|
||||
"""
|
||||
The InvenTreePlugin class is used to integrate with 3rd party software
|
||||
|
||||
DO NOT USE THIS DIRECTLY, USE plugin.InvenTreePlugin
|
||||
"""
|
||||
|
||||
AUTHOR = None
|
||||
DESCRIPTION = None
|
||||
PUBLISH_DATE = None
|
||||
VERSION = None
|
||||
WEBSITE = None
|
||||
LICENSE = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_mixin('base')
|
||||
self.def_path = inspect.getfile(self.__class__)
|
||||
self.path = os.path.dirname(self.def_path)
|
||||
|
||||
self.define_package()
|
||||
|
||||
# region properties
|
||||
@property
|
||||
def description(self):
|
||||
"""
|
||||
Description of plugin
|
||||
"""
|
||||
description = getattr(self, 'DESCRIPTION', None)
|
||||
if not description:
|
||||
description = self.plugin_name()
|
||||
return description
|
||||
|
||||
@property
|
||||
def author(self):
|
||||
"""
|
||||
Author of plugin - either from plugin settings or git
|
||||
"""
|
||||
author = getattr(self, 'AUTHOR', None)
|
||||
if not author:
|
||||
author = self.package.get('author')
|
||||
if not author:
|
||||
author = _('No author found') # pragma: no cover
|
||||
return author
|
||||
|
||||
@property
|
||||
def pub_date(self):
|
||||
"""
|
||||
Publishing date of plugin - either from plugin settings or git
|
||||
"""
|
||||
pub_date = getattr(self, 'PUBLISH_DATE', None)
|
||||
if not pub_date:
|
||||
pub_date = self.package.get('date')
|
||||
else:
|
||||
pub_date = datetime.fromisoformat(str(pub_date))
|
||||
if not pub_date:
|
||||
pub_date = _('No date found') # pragma: no cover
|
||||
return pub_date
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
"""
|
||||
Version of plugin
|
||||
"""
|
||||
version = getattr(self, 'VERSION', None)
|
||||
return version
|
||||
|
||||
@property
|
||||
def website(self):
|
||||
"""
|
||||
Website of plugin - if set else None
|
||||
"""
|
||||
website = getattr(self, 'WEBSITE', None)
|
||||
return website
|
||||
|
||||
@property
|
||||
def license(self):
|
||||
"""
|
||||
License of plugin
|
||||
"""
|
||||
lic = getattr(self, 'LICENSE', None)
|
||||
return lic
|
||||
# endregion
|
||||
|
||||
@property
|
||||
def _is_package(self):
|
||||
"""
|
||||
Is the plugin delivered as a package
|
||||
"""
|
||||
return getattr(self, 'is_package', False)
|
||||
|
||||
@property
|
||||
def is_sample(self):
|
||||
"""
|
||||
Is this plugin part of the samples?
|
||||
"""
|
||||
path = str(self.package_path)
|
||||
return path.startswith('plugin/samples/')
|
||||
|
||||
@property
|
||||
def package_path(self):
|
||||
"""
|
||||
Path to the plugin
|
||||
"""
|
||||
if self._is_package:
|
||||
return self.__module__ # pragma: no cover
|
||||
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
|
||||
|
||||
@property
|
||||
def settings_url(self):
|
||||
"""
|
||||
URL to the settings panel for this plugin
|
||||
"""
|
||||
return f'{reverse("settings")}#select-plugin-{self.slug}'
|
||||
|
||||
# region package info
|
||||
def _get_package_commit(self):
|
||||
"""
|
||||
Get last git commit for the plugin
|
||||
"""
|
||||
return get_git_log(self.def_path)
|
||||
|
||||
def _get_package_metadata(self):
|
||||
"""
|
||||
Get package metadata for plugin
|
||||
"""
|
||||
return {} # pragma: no cover # TODO add usage for package metadata
|
||||
|
||||
def define_package(self):
|
||||
"""
|
||||
Add package info of the plugin into plugins context
|
||||
"""
|
||||
package = self._get_package_metadata() if self._is_package else self._get_package_commit()
|
||||
|
||||
# process date
|
||||
if package.get('date'):
|
||||
package['date'] = datetime.fromisoformat(package.get('date'))
|
||||
|
||||
# process sign state
|
||||
sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
|
||||
if sign_state.status == 0:
|
||||
self.sign_color = 'success' # pragma: no cover
|
||||
elif sign_state.status == 1:
|
||||
self.sign_color = 'warning'
|
||||
else:
|
||||
self.sign_color = 'danger' # pragma: no cover
|
||||
|
||||
# set variables
|
||||
self.package = package
|
||||
self.sign_state = sign_state
|
||||
# endregion
|
||||
|
||||
|
||||
class IntegrationPluginBase(InvenTreePlugin):
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Send warning about using this reference"""
|
||||
# TODO remove in 0.8.0
|
||||
warnings.warn("This import is deprecated - use InvenTreePlugin", DeprecationWarning)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -12,7 +12,7 @@ import os
|
||||
import subprocess
|
||||
|
||||
from typing import OrderedDict
|
||||
from importlib import reload
|
||||
from importlib import reload, metadata
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
@ -22,16 +22,10 @@ from django.urls import clear_url_caches
|
||||
from django.contrib import admin
|
||||
from django.utils.text import slugify
|
||||
|
||||
try:
|
||||
from importlib import metadata
|
||||
except: # pragma: no cover
|
||||
import importlib_metadata as metadata
|
||||
# TODO remove when python minimum is 3.8
|
||||
|
||||
from maintenance_mode.core import maintenance_mode_on
|
||||
from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
|
||||
|
||||
from .integration import IntegrationPluginBase
|
||||
from .plugin import InvenTreePlugin
|
||||
from .helpers import handle_error, log_error, get_plugins, IntegrationPluginError
|
||||
|
||||
|
||||
@ -57,7 +51,6 @@ class PluginsRegistry:
|
||||
self.apps_loading = True # Marks if apps were reloaded yet
|
||||
self.git_is_modern = True # Is a modern version of git available
|
||||
|
||||
# integration specific
|
||||
self.installed_apps = [] # Holds all added plugin_paths
|
||||
|
||||
# mixins
|
||||
@ -129,7 +122,7 @@ class PluginsRegistry:
|
||||
log_error({error.path: error.message}, 'load')
|
||||
blocked_plugin = error.path # we will not try to load this app again
|
||||
|
||||
# Initialize apps without any integration plugins
|
||||
# Initialize apps without any plugins
|
||||
self._clean_registry()
|
||||
self._clean_installed_apps()
|
||||
self._activate_plugins(force_reload=True)
|
||||
@ -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:
|
||||
|
0
InvenTree/plugin/samples/event/__init__.py
Normal file
0
InvenTree/plugin/samples/event/__init__.py
Normal file
@ -2,18 +2,18 @@
|
||||
Sample plugin which responds to events
|
||||
"""
|
||||
|
||||
from plugin import IntegrationPluginBase
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import EventMixin
|
||||
|
||||
|
||||
class EventPluginSample(EventMixin, IntegrationPluginBase):
|
||||
class EventPluginSample(EventMixin, InvenTreePlugin):
|
||||
"""
|
||||
A sample plugin which provides supports for triggered events
|
||||
"""
|
||||
|
||||
PLUGIN_NAME = "EventPlugin"
|
||||
PLUGIN_SLUG = "event"
|
||||
PLUGIN_TITLE = "Triggered Events"
|
||||
NAME = "EventPlugin"
|
||||
SLUG = "event"
|
||||
TITLE = "Triggered Events"
|
||||
|
||||
def process_event(self, event, *args, **kwargs):
|
||||
""" Custom event processing """
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user