diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index e772447025..929a299e93 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -25,28 +25,9 @@ env: jobs: - - check_version: - name: version number - runs-on: ubuntu-latest - if: ${{ github.event_name == 'pull_request' }} - - steps: - - name: Checkout Code - uses: actions/checkout@v2 - - name: Check version number - if: ${{ github.event_name == 'pull_request' }} - run: | - python3 ci/check_version_number.py --branch ${{ github.base_ref }} - - name: Finish - if: always() - run: echo 'done' - pep_style: name: PEP style (python) - needs: check_version runs-on: ubuntu-latest - if: always() steps: - name: Checkout code diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml new file mode 100644 index 0000000000..73d5bd8a2c --- /dev/null +++ b/.github/workflows/version.yml @@ -0,0 +1,21 @@ +# Checks version number +name: version number + +on: + pull_request: + branches-ignore: + - l10* + + +jobs: + + check_version: + name: version number + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: Check version number + run: | + python3 ci/check_version_number.py --branch ${{ github.base_ref }} diff --git a/.gitignore b/.gitignore index 488f8889b5..4bc0bb1389 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,10 @@ dev/ locale_stats.json # node.js -node_modules/ \ No newline at end of file +node_modules/ + +# maintenance locker +maintenance_mode_state.txt + +# plugin dev directory +plugins/ diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index a006050694..8249a093aa 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -21,14 +21,14 @@ from .views import AjaxView from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName from .status import is_worker_running -from plugins import plugins as inventree_plugins +from plugin.plugins import load_action_plugins logger = logging.getLogger("inventree") logger.info("Loading action plugins...") -action_plugins = inventree_plugins.load_action_plugins() +action_plugins = load_action_plugins() class InfoView(AjaxView): diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 5f347dd1e5..faef1a6cdb 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -18,11 +18,32 @@ class InvenTreeConfig(AppConfig): def ready(self): if canAppAccessDatabase(): + + self.remove_obsolete_tasks() + self.start_background_tasks() if not isInTestMode(): self.update_exchange_rates() + def remove_obsolete_tasks(self): + """ + Delete any obsolete scheduled tasks in the database + """ + + obsolete = [ + 'InvenTree.tasks.delete_expired_sessions', + 'stock.tasks.delete_old_stock_items', + ] + + try: + from django_q.models import Schedule + except (AppRegistryNotReady): + return + + # Remove any existing obsolete tasks + Schedule.objects.filter(func__in=obsolete).delete() + def start_background_tasks(self): try: @@ -57,25 +78,12 @@ class InvenTreeConfig(AppConfig): schedule_type=Schedule.DAILY, ) - # Remove expired sessions - InvenTree.tasks.schedule_task( - 'InvenTree.tasks.delete_expired_sessions', - schedule_type=Schedule.DAILY, - ) - # Delete old error messages InvenTree.tasks.schedule_task( 'InvenTree.tasks.delete_old_error_logs', schedule_type=Schedule.DAILY, ) - # Delete "old" stock items - InvenTree.tasks.schedule_task( - 'stock.tasks.delete_old_stock_items', - schedule_type=Schedule.MINUTES, - minutes=30, - ) - # Delete old notification records InvenTree.tasks.schedule_task( 'common.tasks.delete_old_notifications', diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index 9981e52ff7..936dd7a76e 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -2,6 +2,7 @@ from common.settings import currency_code_default, currency_codes from urllib.error import HTTPError, URLError from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend +from django.db.utils import OperationalError class InvenTreeExchange(SimpleExchangeBackend): @@ -32,3 +33,12 @@ class InvenTreeExchange(SimpleExchangeBackend): # catch connection errors except (HTTPError, URLError): print('Encountered connection error while updating') + except OperationalError as e: + if 'SerializationFailure' in e.__cause__.__class__.__name__: + print('Serialization Failure while updating exchange rates') + # We are just going to swallow this exception because the + # exchange rates will be updated later by the scheduled task + else: + # Other operational errors probably are still show stoppers + # so reraise them so that the log contains the stacktrace + raise diff --git a/InvenTree/InvenTree/plugins.py b/InvenTree/InvenTree/plugins.py deleted file mode 100644 index 8da725bf9c..0000000000 --- a/InvenTree/InvenTree/plugins.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- - -import inspect -import importlib -import pkgutil - - -def iter_namespace(pkg): - - return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".") - - -def get_modules(pkg): - # Return all modules in a given package - return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)] - - -def get_classes(module): - # Return all classes in a given module - return inspect.getmembers(module, inspect.isclass) - - -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 - """ - - plugins = [] - - modules = get_modules(pkg) - - # Iterate through each module in the package - for mod in modules: - # Iterate through each class in the module - for item in get_classes(mod): - plugin = item[1] - if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME: - plugins.append(plugin) - - return plugins diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index df84ba315e..c37ae886b1 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -86,6 +86,9 @@ if not os.path.exists(cfg_filename): with open(cfg_filename, 'r') as cfg: CONFIG = yaml.safe_load(cfg) +# We will place any config files in the same directory as the config file +config_dir = os.path.dirname(cfg_filename) + # Default action is to run the system in Debug mode # SECURITY WARNING: don't run with debug turned on in production! DEBUG = _is_true(get_setting( @@ -130,6 +133,11 @@ LOGGING = { 'handlers': ['console'], 'level': log_level, }, + 'filters': { + 'require_not_maintenance_mode_503': { + '()': 'maintenance_mode.logging.RequireNotMaintenanceMode503', + }, + }, } # Get a logger instance for this setup file @@ -201,6 +209,12 @@ if MEDIA_ROOT is None: print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined") sys.exit(1) +# Options for django-maintenance-mode : https://pypi.org/project/django-maintenance-mode/ +MAINTENANCE_MODE_STATE_FILE_PATH = os.path.join( + config_dir, + 'maintenance_mode_state.txt', +) + # List of allowed hosts (default = allow all) ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*']) @@ -262,6 +276,9 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django.contrib.sites', + # Maintenance + 'maintenance_mode', + # InvenTree apps 'build.apps.BuildConfig', 'common.apps.CommonConfig', @@ -272,6 +289,7 @@ INSTALLED_APPS = [ 'report.apps.ReportConfig', 'stock.apps.StockConfig', 'users.apps.UsersConfig', + 'plugin.apps.PluginAppConfig', 'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last # Third part add-ons @@ -308,6 +326,7 @@ MIDDLEWARE = CONFIG.get('middleware', [ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'InvenTree.middleware.AuthRequiredMiddleware', + 'maintenance_mode.middleware.MaintenanceModeMiddleware', ]) # Error reporting middleware @@ -335,7 +354,6 @@ TEMPLATES = [ os.path.join(MEDIA_ROOT, 'report'), os.path.join(MEDIA_ROOT, 'label'), ], - 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', @@ -348,6 +366,13 @@ TEMPLATES = [ 'InvenTree.context.status_codes', 'InvenTree.context.user_roles', ], + 'loaders': [( + 'django.template.loaders.cached.Loader', [ + 'plugin.loader.PluginTemplateLoader', + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ]) + ], }, }, ] @@ -558,7 +583,7 @@ _cache_port = _cache_config.get( if _cache_host: # We are going to rely upon a possibly non-localhost for our cache, # so don't wait too long for the cache as nothing in the cache should be - # irreplacable. Django Q Cluster will just try again later. + # irreplacable. _cache_options = { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SOCKET_CONNECT_TIMEOUT": int(os.getenv("CACHE_CONNECT_TIMEOUT", "2")), @@ -584,15 +609,9 @@ if _cache_host: }, } CACHES = { - # Connection configuration for Django Q Cluster - "worker": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": f"redis://{_cache_host}:{_cache_port}/0", - "OPTIONS": _cache_options, - }, "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": f"redis://{_cache_host}:{_cache_port}/1", + "LOCATION": f"redis://{_cache_host}:{_cache_port}/0", "OPTIONS": _cache_options, }, } @@ -876,3 +895,23 @@ MARKDOWNIFY_WHITELIST_ATTRS = [ ] MARKDOWNIFY_BLEACH = False + +# Maintenance mode +MAINTENANCE_MODE_RETRY_AFTER = 60 + + +# Plugins +PLUGIN_DIRS = ['plugin.builtin', ] + +if not TESTING: + # load local deploy directory in prod + PLUGIN_DIRS.append('plugins') + +if DEBUG or TESTING: + # load samples in debug mode + PLUGIN_DIRS.append('plugin.samples') + +# Plugin test settings +PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING) # are plugins beeing tested? +PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False) # load plugins from setup hooks in testing? +PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried? diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index a61696f547..98aead45ee 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -438,6 +438,12 @@ width: 30%; } +/* tracking table column size */ +#track-table .table-condensed th { + inline-size: 30%; + overflow-wrap: break-word; +} + .panel-heading .badge { float: right; } diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index adb9c0a370..18c3bcc564 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -231,25 +231,6 @@ def check_for_updates(): ) -def delete_expired_sessions(): - """ - Remove any expired user sessions from the database - """ - - try: - from django.contrib.sessions.models import Session - - # Delete any sessions that expired more than a day ago - expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1)) - - if expired.count() > 0: - logger.info(f"Deleting {expired.count()} expired sessions.") - expired.delete() - - except AppRegistryNotReady: - logger.info("Could not perform 'delete_expired_sessions' - App registry not ready") - - def update_exchange_rates(): """ Update currency exchange rates diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 8286adcad2..57d5c58ed9 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -18,6 +18,7 @@ from part.urls import part_urls from stock.urls import stock_urls from build.urls import build_urls from order.urls import order_urls +from plugin.urls import get_plugin_urls from barcodes.api import barcode_api_urls from common.api import common_api_urls, settings_api_urls @@ -28,6 +29,7 @@ from build.api import build_api_urls 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 django.conf import settings from django.conf.urls.static import static @@ -62,6 +64,7 @@ apipatterns = [ url(r'^order/', include(order_api_urls)), url(r'^label/', include(label_api_urls)), url(r'^report/', include(report_api_urls)), + url(r'^plugin/', include(plugin_api_urls)), # User URLs url(r'^user/', include(user_urls)), @@ -123,6 +126,7 @@ translated_javascript_urls = [ url(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'), url(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'), url(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'), + url(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'), url(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'), url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'), ] @@ -167,6 +171,9 @@ urlpatterns = [ url(r'^api/', include(apipatterns)), url(r'^api-doc/', include_docs_urls(title='InvenTree API')), + # plugin urls + get_plugin_urls(), # appends currently loaded plugin urls = None + url(r'^markdownx/', include('markdownx.urls')), # DB user sessions diff --git a/InvenTree/barcodes/api.py b/InvenTree/barcodes/api.py index 6ab848c3f6..4b853ab438 100644 --- a/InvenTree/barcodes/api.py +++ b/InvenTree/barcodes/api.py @@ -12,7 +12,8 @@ from rest_framework.views import APIView from stock.models import StockItem from stock.serializers import StockItemSerializer -from barcodes.barcode import load_barcode_plugins, hash_barcode +from barcodes.barcode import hash_barcode +from plugin.plugins import load_barcode_plugins class BarcodeScan(APIView): @@ -187,21 +188,21 @@ class BarcodeAssign(APIView): if plugin.getStockItem() is not None: match_found = True - response['error'] = _('Barcode already matches StockItem object') + response['error'] = _('Barcode already matches Stock Item') if plugin.getStockLocation() is not None: match_found = True - response['error'] = _('Barcode already matches StockLocation object') + response['error'] = _('Barcode already matches Stock Location') if plugin.getPart() is not None: match_found = True - response['error'] = _('Barcode already matches Part object') + response['error'] = _('Barcode already matches Part') if not match_found: item = plugin.getStockItemByHash() if item is not None: - response['error'] = _('Barcode hash already matches StockItem object') + response['error'] = _('Barcode hash already matches Stock Item') match_found = True else: @@ -213,13 +214,13 @@ class BarcodeAssign(APIView): # Lookup stock item by hash try: item = StockItem.objects.get(uid=hash) - response['error'] = _('Barcode hash already matches StockItem object') + response['error'] = _('Barcode hash already matches Stock Item') match_found = True except StockItem.DoesNotExist: pass if not match_found: - response['success'] = _('Barcode associated with StockItem') + response['success'] = _('Barcode associated with Stock Item') # Save the barcode hash item.uid = response['hash'] diff --git a/InvenTree/barcodes/barcode.py b/InvenTree/barcodes/barcode.py index 7ab9f3716a..51f8a1ffa1 100644 --- a/InvenTree/barcodes/barcode.py +++ b/InvenTree/barcodes/barcode.py @@ -4,8 +4,6 @@ import string import hashlib import logging -from InvenTree import plugins as InvenTreePlugins -from barcodes import plugins as BarcodePlugins from stock.models import StockItem from stock.serializers import StockItemSerializer, LocationSerializer @@ -139,24 +137,3 @@ class BarcodePlugin: Default implementation returns False """ return False - - -def load_barcode_plugins(debug=False): - """ - Function to load all barcode plugins - """ - - logger.debug("Loading barcode plugins") - - plugins = InvenTreePlugins.get_plugins(BarcodePlugins, BarcodePlugin) - - if debug: - if len(plugins) > 0: - logger.info(f"Discovered {len(plugins)} barcode plugins") - - for p in plugins: - logger.debug(" - {p}".format(p=p.PLUGIN_NAME)) - else: - logger.debug("No barcode plugins found") - - return plugins diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index f8c381f224..3ecb630c87 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -10,7 +10,6 @@ from InvenTree import status_codes as status from build.models import Build, BuildItem, get_next_build_number from part.models import Part, BomItem from stock.models import StockItem -from stock.tasks import delete_old_stock_items class BuildTest(TestCase): @@ -354,11 +353,6 @@ class BuildTest(TestCase): # the original BuildItem objects should have been deleted! self.assertEqual(BuildItem.objects.count(), 0) - self.assertEqual(StockItem.objects.count(), 8) - - # Clean up old stock items - delete_old_stock_items() - # New stock items should have been created! self.assertEqual(StockItem.objects.count(), 7) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 5ccd9cf7a7..1052e48b53 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -962,6 +962,34 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': '', 'choices': settings_group_options }, + 'ENABLE_PLUGINS_URL': { + 'name': _('Enable URL integration'), + 'description': _('Enable plugins to add URL routes'), + 'default': False, + 'validator': bool, + 'requires_restart': True, + }, + 'ENABLE_PLUGINS_NAVIGATION': { + 'name': _('Enable navigation integration'), + 'description': _('Enable plugins to integrate into navigation'), + 'default': False, + 'validator': bool, + 'requires_restart': True, + }, + 'ENABLE_PLUGINS_GLOBALSETTING': { + 'name': _('Enable global setting integration'), + 'description': _('Enable plugins to integrate into inventree global settings'), + 'default': False, + 'validator': bool, + 'requires_restart': True, + }, + 'ENABLE_PLUGINS_APP': { + 'name': _('Enable app integration'), + 'description': _('Enable plugins to add apps'), + 'default': False, + 'validator': bool, + 'requires_restart': True, + }, } class Meta: diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 98b9bbe934..96b886e15c 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -277,13 +277,31 @@ class POLineItemFilter(rest_filters.FilterSet): 'part' ] - completed = rest_filters.BooleanFilter(label='completed', method='filter_completed') + pending = rest_filters.BooleanFilter(label='pending', method='filter_pending') - def filter_completed(self, queryset, name, value): + def filter_pending(self, queryset, name, value): + """ + Filter by "pending" status (order status = pending) """ - Filter by lines which are "completed" (or "not" completed) - A line is completed when received >= quantity + value = str2bool(value) + + if value: + queryset = queryset.filter(order__status__in=PurchaseOrderStatus.OPEN) + else: + queryset = queryset.exclude(order__status__in=PurchaseOrderStatus.OPEN) + + return queryset + + order_status = rest_filters.NumberFilter(label='order_status', field_name='order__status') + + received = rest_filters.BooleanFilter(label='received', method='filter_received') + + def filter_received(self, queryset, name, value): + """ + Filter by lines which are "received" (or "not" received) + + A line is considered "received" when received >= quantity """ value = str2bool(value) @@ -293,7 +311,8 @@ class POLineItemFilter(rest_filters.FilterSet): if value: queryset = queryset.filter(q) else: - queryset = queryset.exclude(q) + # Only count "pending" orders + queryset = queryset.exclude(q).filter(order__status__in=PurchaseOrderStatus.OPEN) return queryset diff --git a/InvenTree/plugin/__init__.py b/InvenTree/plugin/__init__.py new file mode 100644 index 0000000000..973d341171 --- /dev/null +++ b/InvenTree/plugin/__init__.py @@ -0,0 +1,7 @@ +from .registry import plugins as plugin_reg +from .integration import IntegrationPluginBase +from .action import ActionPlugin + +__all__ = [ + 'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin', +] diff --git a/InvenTree/plugins/action/action.py b/InvenTree/plugin/action.py similarity index 74% rename from InvenTree/plugins/action/action.py rename to InvenTree/plugin/action.py index 8b9ed6aec9..5e36c22e74 100644 --- a/InvenTree/plugins/action/action.py +++ b/InvenTree/plugin/action.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- +"""Class for ActionPlugin""" import logging -import plugins.plugin as plugin +import plugin.plugin as plugin logger = logging.getLogger("inventree") @@ -42,7 +43,6 @@ class ActionPlugin(plugin.InvenTreePlugin): """ Override this method to perform the action! """ - pass def get_result(self): """ @@ -68,25 +68,3 @@ class ActionPlugin(plugin.InvenTreePlugin): "result": self.get_result(), "info": self.get_info(), } - - -class SimpleActionPlugin(ActionPlugin): - """ - An EXTREMELY simple action plugin which demonstrates - the capability of the ActionPlugin class - """ - - PLUGIN_NAME = "SimpleActionPlugin" - ACTION_NAME = "simple" - - def perform_action(self): - print("Action plugin in action!") - - def get_info(self): - return { - "user": self.user.username, - "hello": "world", - } - - def get_result(self): - return True diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py new file mode 100644 index 0000000000..3a96a4b9ea --- /dev/null +++ b/InvenTree/plugin/admin.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.contrib import admin + +import plugin.models as models +from plugin import plugin_reg + + +def plugin_update(queryset, new_status: bool): + """general function for bulk changing plugins""" + apps_changed = False + + # run through all plugins in the queryset as the save method needs to be overridden + for plugin in queryset: + if plugin.active is not new_status: + plugin.active = new_status + plugin.save(no_reload=True) + apps_changed = True + + # reload plugins if they changed + if apps_changed: + plugin_reg.reload_plugins() + + +@admin.action(description='Activate plugin(s)') +def plugin_activate(modeladmin, request, queryset): + """activate a set of plugins""" + plugin_update(queryset, True) + + +@admin.action(description='Deactivate plugin(s)') +def plugin_deactivate(modeladmin, request, queryset): + """deactivate a set of plugins""" + plugin_update(queryset, False) + + +class PluginConfigAdmin(admin.ModelAdmin): + """Custom admin with restricted id fields""" + readonly_fields = ["key", "name", ] + list_display = ['active', '__str__', 'key', 'name', ] + list_filter = ['active'] + actions = [plugin_activate, plugin_deactivate, ] + + +admin.site.register(models.PluginConfig, PluginConfigAdmin) diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py new file mode 100644 index 0000000000..4aecd6bb24 --- /dev/null +++ b/InvenTree/plugin/api.py @@ -0,0 +1,89 @@ +""" +JSON API for the plugin app +""" + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.conf.urls import url, include + +from rest_framework import generics +from rest_framework import status +from rest_framework.response import Response + +from plugin.models import PluginConfig +import plugin.serializers as PluginSerializers + + +class PluginList(generics.ListAPIView): + """ API endpoint for list of PluginConfig objects + + - GET: Return a list of all PluginConfig objects + """ + + serializer_class = PluginSerializers.PluginConfigSerializer + queryset = PluginConfig.objects.all() + + ordering_fields = [ + 'key', + 'name', + 'active', + ] + + ordering = [ + 'key', + ] + + search_fields = [ + 'key', + 'name', + ] + + +class PluginDetail(generics.RetrieveUpdateDestroyAPIView): + """ API detail endpoint for PluginConfig object + + get: + Return a single PluginConfig object + + post: + Update a PluginConfig + + delete: + Remove a PluginConfig + """ + + queryset = PluginConfig.objects.all() + serializer_class = PluginSerializers.PluginConfigSerializer + + +class PluginInstall(generics.CreateAPIView): + """ + Endpoint for installing a new plugin + """ + queryset = PluginConfig.objects.none() + serializer_class = PluginSerializers.PluginConfigInstallSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + result = self.perform_create(serializer) + result['input'] = serializer.data + headers = self.get_success_headers(serializer.data) + return Response(result, status=status.HTTP_201_CREATED, headers=headers) + + def perform_create(self, serializer): + return serializer.save() + + +plugin_api_urls = [ + # Detail views for a single PluginConfig item + url(r'^(?P\d+)/', include([ + url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'), + ])), + + url(r'^install/', PluginInstall.as_view(), name='api-plugin-install'), + + # Anything else + url(r'^.*$', PluginList.as_view(), name='api-plugin-list'), +] diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py new file mode 100644 index 0000000000..8a3cd97889 --- /dev/null +++ b/InvenTree/plugin/apps.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.apps import AppConfig +from maintenance_mode.core import set_maintenance_mode + +from plugin.registry import plugins + + +class PluginAppConfig(AppConfig): + name = 'plugin' + + def ready(self): + if not plugins.is_loading: + # this is the first startup + plugins.collect_plugins() + plugins.load_plugins() + + # drop out of maintenance + # makes sure we did not have an error in reloading and maintenance is still active + set_maintenance_mode(False) diff --git a/InvenTree/plugins/action/__init__.py b/InvenTree/plugin/builtin/__init__.py similarity index 100% rename from InvenTree/plugins/action/__init__.py rename to InvenTree/plugin/builtin/__init__.py diff --git a/InvenTree/plugin/builtin/action/__init__.py b/InvenTree/plugin/builtin/action/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/builtin/action/simpleactionplugin.py b/InvenTree/plugin/builtin/action/simpleactionplugin.py new file mode 100644 index 0000000000..01a0829887 --- /dev/null +++ b/InvenTree/plugin/builtin/action/simpleactionplugin.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +"""sample implementation for ActionPlugin""" +from plugin.action import ActionPlugin + + +class SimpleActionPlugin(ActionPlugin): + """ + An EXTREMELY simple action plugin which demonstrates + the capability of the ActionPlugin class + """ + + PLUGIN_NAME = "SimpleActionPlugin" + ACTION_NAME = "simple" + + def perform_action(self): + print("Action plugin in action!") + + def get_info(self): + return { + "user": self.user.username, + "hello": "world", + } + + def get_result(self): + return True diff --git a/InvenTree/plugin/builtin/action/test_samples_action.py b/InvenTree/plugin/builtin/action/test_samples_action.py new file mode 100644 index 0000000000..6406810894 --- /dev/null +++ b/InvenTree/plugin/builtin/action/test_samples_action.py @@ -0,0 +1,40 @@ +""" Unit tests for action plugins """ + +from django.test import TestCase +from django.contrib.auth import get_user_model + +from plugin.builtin.action.simpleactionplugin import SimpleActionPlugin + + +class SimpleActionPluginTests(TestCase): + """ Tests for SampleIntegrationPlugin """ + + 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') + self.plugin = SimpleActionPlugin(user=self.test_user) + + def test_name(self): + """check plugn names """ + self.assertEqual(self.plugin.plugin_name(), "SimpleActionPlugin") + self.assertEqual(self.plugin.action_name(), "simple") + + def test_function(self): + """check if functions work """ + # test functions + response = self.client.post('/api/action/', data={'action': "simple", 'data': {'foo': "bar", }}) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + str(response.content, encoding='utf8'), + { + "action": 'simple', + "result": True, + "info": { + "user": "testuser", + "hello": "world", + }, + } + ) diff --git a/InvenTree/plugin/builtin/integration/__init__.py b/InvenTree/plugin/builtin/integration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py new file mode 100644 index 0000000000..3a6b558db7 --- /dev/null +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -0,0 +1,169 @@ +"""default mixins for IntegrationMixins""" +from django.conf.urls import url, include + +from plugin.urls import PLUGIN_BASE + + +class GlobalSettingsMixin: + """Mixin that enables global settings for the plugin""" + class MixinMeta: + """meta options for this mixin""" + MIXIN_NAME = 'Global settings' + + def __init__(self): + super().__init__() + self.add_mixin('globalsettings', 'has_globalsettings', __class__) + self.globalsettings = self.setup_globalsettings() + + def setup_globalsettings(self): + """ + setup global settings for this plugin + """ + return getattr(self, 'GLOBALSETTINGS', None) + + @property + def has_globalsettings(self): + """ + does this plugin use custom global settings + """ + return bool(self.globalsettings) + + @property + def globalsettingspatterns(self): + """ + get patterns for InvenTreeSetting defintion + """ + if self.has_globalsettings: + return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.globalsettings.items()} + return None + + def _globalsetting_name(self, key): + """get global name of setting""" + return f'PLUGIN_{self.slug.upper()}_{key}' + + def get_globalsetting(self, key): + """ + get plugin global setting by key + """ + from common.models import InvenTreeSetting + return InvenTreeSetting.get_setting(self._globalsetting_name(key)) + + def set_globalsetting(self, key, value, user): + """ + set plugin global setting by key + """ + from common.models import InvenTreeSetting + return InvenTreeSetting.set_setting(self._globalsetting_name(key), value, user) + + +class UrlsMixin: + """Mixin that enables urls for the plugin""" + class MixinMeta: + """meta options for this mixin""" + MIXIN_NAME = 'URLs' + + def __init__(self): + super().__init__() + self.add_mixin('urls', 'has_urls', __class__) + self.urls = self.setup_urls() + + def setup_urls(self): + """ + setup url endpoints for this plugin + """ + return getattr(self, 'URLS', None) + + @property + def base_url(self): + """ + returns base url for this plugin + """ + return f'{PLUGIN_BASE}/{self.slug}/' + + @property + def internal_name(self): + """ + returns the internal url pattern name + """ + return f'plugin:{self.slug}:' + + @property + def urlpatterns(self): + """ + returns the urlpatterns for this plugin + """ + if self.has_urls: + return url(f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug) + return None + + @property + def has_urls(self): + """ + does this plugin use custom urls + """ + return bool(self.urls) + + +class NavigationMixin: + """Mixin that enables adding navigation links with the plugin""" + NAVIGATION_TAB_NAME = None + NAVIGATION_TAB_ICON = "fas fa-question" + + class MixinMeta: + """meta options for this mixin""" + MIXIN_NAME = 'Navigation Links' + + def __init__(self): + super().__init__() + self.add_mixin('navigation', 'has_naviation', __class__) + self.navigation = self.setup_navigation() + + def setup_navigation(self): + """ + setup navigation links for this plugin + """ + nav_links = getattr(self, 'NAVIGATION', None) + if nav_links: + # check if needed values are configured + for link in nav_links: + if False in [a in link for a in ('link', 'name', )]: + raise NotImplementedError('Wrong Link definition', link) + return nav_links + + @property + def has_naviation(self): + """ + does this plugin define navigation elements + """ + return bool(self.navigation) + + @property + def navigation_name(self): + """name for navigation tab""" + name = getattr(self, 'NAVIGATION_TAB_NAME', None) + if not name: + name = self.human_name + return name + + @property + def navigation_icon(self): + """icon for navigation tab""" + return getattr(self, 'NAVIGATION_TAB_ICON', "fas fa-question") + + +class AppMixin: + """Mixin that enables full django app functions for a plugin""" + class MixinMeta: + """meta options for this mixin""" + MIXIN_NAME = 'App registration' + + def __init__(self): + super().__init__() + self.add_mixin('app', 'has_app', __class__) + + @property + def has_app(self): + """ + this plugin is always an app with this plugin + """ + return True diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py new file mode 100644 index 0000000000..003ca707b4 --- /dev/null +++ b/InvenTree/plugin/helpers.py @@ -0,0 +1,102 @@ +"""Helpers for plugin app""" +import os +import subprocess +import pathlib +import sysconfig +import traceback + +from django.conf import settings + + +# region logging / errors +def log_plugin_error(error, reference: str = 'general'): + from plugin import plugin_reg + + # make sure the registry is set up + if reference not in plugin_reg.errors: + plugin_reg.errors[reference] = [] + + # add error to stack + plugin_reg.errors[reference].append(error) + + +class IntegrationPluginError(Exception): + def __init__(self, path, message): + self.path = path + self.message = message + + def __str__(self): + return self.message + + +def get_plugin_error(error, do_raise: bool = False, do_log: bool = False, log_name: str = ''): + package_path = traceback.extract_tb(error.__traceback__)[-1].filename + install_path = sysconfig.get_paths()["purelib"] + try: + package_name = pathlib.Path(package_path).relative_to(install_path).parts[0] + except ValueError: + # is file - loaded -> form a name for that + path_obj = pathlib.Path(package_path).relative_to(settings.BASE_DIR) + path_parts = [*path_obj.parts] + path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix + + # remove path preixes + if path_parts[0] == 'plugin': + path_parts.remove('plugin') + path_parts.pop(0) + else: + path_parts.remove('plugins') + + package_name = '.'.join(path_parts) + + if do_log: + log_kwargs = {} + if log_name: + log_kwargs['reference'] = log_name + log_plugin_error({package_name: str(error)}, **log_kwargs) + + if do_raise: + raise IntegrationPluginError(package_name, str(error)) + + return package_name, str(error) +# endregion + + +# region git-helpers +def get_git_log(path): + """get dict with info of the last commit to file named in path""" + path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:] + command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path] + try: + output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1] + if output: + output = output.split('\n') + else: + output = 7 * [''] + except subprocess.CalledProcessError: + output = 7 * [''] + return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]} + + +class GitStatus: + """class for resolving git gpg singing state""" + class Definition: + """definition of a git gpg sing state""" + key: str = 'N' + status: int = 2 + msg: str = '' + + def __init__(self, key: str = 'N', status: int = 2, msg: str = '') -> None: + self.key = key + self.status = status + self.msg = msg + + N = Definition(key='N', status=2, msg='no signature',) + G = Definition(key='G', status=0, msg='valid signature',) + B = Definition(key='B', status=2, msg='bad signature',) + U = Definition(key='U', status=1, msg='good signature, unknown validity',) + X = Definition(key='X', status=1, msg='good signature, expired',) + Y = Definition(key='Y', status=1, msg='good signature, expired key',) + R = Definition(key='R', status=2, msg='good signature, revoked key',) + E = Definition(key='E', status=1, msg='cannot be checked',) +# endregion diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py new file mode 100644 index 0000000000..3cd8ae86d2 --- /dev/null +++ b/InvenTree/plugin/integration.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +"""class for IntegrationPluginBase and Mixins for it""" + +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.text import slugify +from django.utils.translation import ugettext_lazy as _ + +import plugin.plugin as plugin +from plugin.helpers import get_git_log, GitStatus + + +logger = logging.getLogger("inventree") + + +class MixinBase: + """general base for mixins""" + + 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.InvenTreePlugin): + """ + The IntegrationPluginBase class is used to integrate with 3rd party software + """ + PLUGIN_SLUG = None + PLUGIN_TITLE = None + + 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.set_package() + + @property + def _is_package(self): + return getattr(self, 'is_package', False) + + # region properties + @property + def slug(self): + """slug for the plugin""" + slug = getattr(self, 'PLUGIN_SLUG', None) + if not slug: + slug = self.plugin_name() + return slugify(slug) + + @property + def human_name(self): + """human readable name for labels etc.""" + human_name = getattr(self, 'PLUGIN_TITLE', None) + if not human_name: + human_name = self.plugin_name() + return human_name + + @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): + """returns 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') + return author + + @property + def pub_date(self): + """returns 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') + return pub_date + + @property + def version(self): + """returns version of plugin""" + version = getattr(self, 'VERSION', None) + return version + + @property + def website(self): + """returns website of plugin""" + website = getattr(self, 'WEBSITE', None) + return website + + @property + def license(self): + """returns license of plugin""" + license = getattr(self, 'LICENSE', None) + return license + # endregion + + @property + def package_path(self): + """returns the path to the plugin""" + if self._is_package: + return self.__module__ + return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR) + + @property + def settings_url(self): + """returns url to the settings panel""" + 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 enabled and ready""" + if self.mixin(key): + fnc_name = self._mixins.get(key) + return getattr(self, fnc_name, True) + return False + # endregion + + # region package info + def get_package_commit(self): + """get last git commit for plugin""" + return get_git_log(self.def_path) + + def get_package_metadata(self): + """get package metadata for plugin""" + return {} + + def set_package(self): + """add packaging info of the plugins 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' + elif sign_state.status == 1: + self.sign_color = 'warning' + else: + self.sign_color = 'danger' + + # set variables + self.package = package + self.sign_state = sign_state + # endregion diff --git a/InvenTree/plugin/loader.py b/InvenTree/plugin/loader.py new file mode 100644 index 0000000000..2491336a51 --- /dev/null +++ b/InvenTree/plugin/loader.py @@ -0,0 +1,19 @@ +""" +load templates for loaded plugins +""" +from django.template.loaders.filesystem import Loader as FilesystemLoader +from pathlib import Path + +from plugin import plugin_reg + + +class PluginTemplateLoader(FilesystemLoader): + + def get_dirs(self): + dirname = 'templates' + template_dirs = [] + for plugin in plugin_reg.plugins.values(): + new_path = Path(plugin.path) / dirname + if Path(new_path).is_dir(): + template_dirs.append(new_path) + return tuple(template_dirs) diff --git a/InvenTree/plugin/migrations/0001_initial.py b/InvenTree/plugin/migrations/0001_initial.py new file mode 100644 index 0000000000..1dd7032f69 --- /dev/null +++ b/InvenTree/plugin/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.5 on 2021-11-11 23:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='PluginConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(help_text='Key of plugin', max_length=255, unique=True, verbose_name='Key')), + ('name', models.CharField(blank=True, help_text='PluginName of the plugin', max_length=255, null=True, verbose_name='Name')), + ('active', models.BooleanField(default=False, help_text='Is the plugin active', verbose_name='Active')), + ], + ), + ] diff --git a/InvenTree/plugin/migrations/0002_alter_pluginconfig_options.py b/InvenTree/plugin/migrations/0002_alter_pluginconfig_options.py new file mode 100644 index 0000000000..8917fa9b68 --- /dev/null +++ b/InvenTree/plugin/migrations/0002_alter_pluginconfig_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.5 on 2021-11-15 23:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugin', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='pluginconfig', + options={'verbose_name': 'Plugin Configuration', 'verbose_name_plural': 'Plugin Configurations'}, + ), + ] diff --git a/InvenTree/plugin/migrations/__init__.py b/InvenTree/plugin/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py new file mode 100644 index 0000000000..feb6bc3466 --- /dev/null +++ b/InvenTree/plugin/mixins/__init__.py @@ -0,0 +1,6 @@ +"""utility class to enable simpler imports""" +from ..builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin + +__all__ = [ + 'AppMixin', 'GlobalSettingsMixin', 'UrlsMixin', 'NavigationMixin', +] diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py new file mode 100644 index 0000000000..b8178440af --- /dev/null +++ b/InvenTree/plugin/models.py @@ -0,0 +1,93 @@ +""" +Plugin model definitions +""" + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.utils.translation import gettext_lazy as _ +from django.db import models + +from plugin import plugin_reg + + +class PluginConfig(models.Model): + """ A PluginConfig object holds settings for plugins. + + It is used to designate a Part as 'subscribed' for a given User. + + Attributes: + key: slug of the plugin - must be unique + name: PluginName of the plugin - serves for a manual double check if the right plugin is used + active: Should the plugin be loaded? + """ + class Meta: + verbose_name = _("Plugin Configuration") + verbose_name_plural = _("Plugin Configurations") + + key = models.CharField( + unique=True, + max_length=255, + verbose_name=_('Key'), + help_text=_('Key of plugin'), + ) + + name = models.CharField( + null=True, + blank=True, + max_length=255, + verbose_name=_('Name'), + help_text=_('PluginName of the plugin'), + ) + + active = models.BooleanField( + default=False, + verbose_name=_('Active'), + help_text=_('Is the plugin active'), + ) + + def __str__(self) -> str: + name = f'{self.name} - {self.key}' + if not self.active: + name += '(not active)' + return name + + # extra attributes from the registry + def mixins(self): + return self.plugin._mixinreg + + # functions + + def __init__(self, *args, **kwargs): + """override to set original state of""" + super().__init__(*args, **kwargs) + self.__org_active = self.active + + # append settings from registry + self.plugin = plugin_reg.plugins.get(self.key, None) + + def get_plugin_meta(name): + if self.plugin: + return str(getattr(self.plugin, name, None)) + return None + + self.meta = { + key: get_plugin_meta(key) for key in ['slug', 'human_name', 'description', 'author', + 'pub_date', 'version', 'website', 'license', + 'package_path', 'settings_url', ] + } + + def save(self, force_insert=False, force_update=False, *args, **kwargs): + """extend save method to reload plugins if the 'active' status changes""" + reload = kwargs.pop('no_reload', False) # check if no_reload flag is set + + ret = super().save(force_insert, force_update, *args, **kwargs) + + if not reload: + if self.active is False and self.__org_active is True: + plugin_reg.reload_plugins() + + elif self.active is True and self.__org_active is False: + plugin_reg.reload_plugins() + + return ret diff --git a/InvenTree/plugins/plugin.py b/InvenTree/plugin/plugin.py similarity index 72% rename from InvenTree/plugins/plugin.py rename to InvenTree/plugin/plugin.py index 11de4d1365..93199df7b7 100644 --- a/InvenTree/plugins/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -1,15 +1,17 @@ # -*- coding: utf-8 -*- +"""Base Class for InvenTree plugins""" class InvenTreePlugin(): """ - Base class for a Barcode plugin + Base class for a plugin """ # Override the plugin name for each concrete plugin instance PLUGIN_NAME = '' def plugin_name(self): + """get plugin name""" return self.PLUGIN_NAME def __init__(self): diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py new file mode 100644 index 0000000000..e2be1e6427 --- /dev/null +++ b/InvenTree/plugin/plugins.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +"""general functions for plugin handeling""" + +import inspect +import importlib +import pkgutil +import logging + +from django.core.exceptions import AppRegistryNotReady + +# Action plugins +import plugin.builtin.action as action +from plugin.action import ActionPlugin + + +logger = logging.getLogger("inventree") + + +def iter_namespace(pkg): + """get all modules in a package""" + return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".") + + +def get_modules(pkg, recursive: bool = False): + """get all modules in a package""" + from plugin.helpers import log_plugin_error + + if not recursive: + return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)] + + context = {} + for loader, name, ispkg in pkgutil.walk_packages(pkg.__path__): + try: + module = loader.find_module(name).load_module(name) + pkg_names = getattr(module, '__all__', None) + for k, v in vars(module).items(): + if not k.startswith('_') and (pkg_names is None or k in pkg_names): + context[k] = v + context[name] = module + except AppRegistryNotReady: + pass + except Exception as error: + # this 'protects' against malformed plugin modules by more or less silently failing + + # log to stack + log_plugin_error({name: str(error)}, 'discovery') + + return [v for k, v in context.items()] + + +def get_classes(module): + """get all classes in a given module""" + return inspect.getmembers(module, inspect.isclass) + + +def get_plugins(pkg, baseclass, recursive: bool = False): + """ + 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 + """ + + plugins = [] + + modules = get_modules(pkg, recursive) + + # Iterate through each module in the package + for mod in modules: + # Iterate through each class in the module + for item in get_classes(mod): + plugin = item[1] + if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME: + plugins.append(plugin) + + return plugins + + +def load_plugins(name: str, cls, module): + """general function to load a plugin class + + :param name: name of the plugin for logs + :type name: str + :param module: module from which the plugins should be loaded + :return: class of the to-be-loaded plugin + """ + logger.debug("Loading %s plugins", name) + + plugins = get_plugins(module, cls) + + if len(plugins) > 0: + logger.info("Discovered %i %s plugins:", len(plugins), name) + + for plugin in plugins: + logger.debug(" - %s", plugin.PLUGIN_NAME) + + return plugins + + +def load_action_plugins(): + """ + Return a list of all registered action plugins + """ + return load_plugins('action', ActionPlugin, action) + + +def load_barcode_plugins(): + """ + Return a list of all registered barcode plugins + """ + from barcodes import plugins as BarcodePlugins + from barcodes.barcode import BarcodePlugin + + return load_plugins('barcode', BarcodePlugin, BarcodePlugins) diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py new file mode 100644 index 0000000000..d16575af2b --- /dev/null +++ b/InvenTree/plugin/registry.py @@ -0,0 +1,452 @@ +""" +registry for plugins +holds the class and the object that contains all code to maintain plugin states +""" +import importlib +import pathlib +import logging +from typing import OrderedDict +from importlib import reload + +from django.apps import apps +from django.conf import settings +from django.db.utils import OperationalError, ProgrammingError +from django.conf.urls import url +from django.urls import clear_url_caches +from django.contrib import admin +from django.utils.text import slugify + +try: + from importlib import metadata +except: + 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 plugin import plugins as inventree_plugins +from .integration import IntegrationPluginBase +from .helpers import get_plugin_error, IntegrationPluginError + + +logger = logging.getLogger('inventree') + + +class Plugins: + def __init__(self) -> None: + # plugin registry + self.plugins = {} + self.plugins_inactive = {} + + self.plugin_modules = [] # Holds all discovered plugins + + self.errors = {} # Holds discovering errors + + # flags + self.is_loading = False + self.apps_loading = True # Marks if apps were reloaded yet + + # integration specific + self.installed_apps = [] # Holds all added plugin_paths + # mixins + self.mixins_globalsettings = {} + + # region public plugin functions + def load_plugins(self): + """load and activate all IntegrationPlugins""" + from plugin.helpers import log_plugin_error + + logger.info('Start loading plugins') + # set maintanace mode + _maintenance = bool(get_maintenance_mode()) + if not _maintenance: + set_maintenance_mode(True) + + registered_sucessfull = False + blocked_plugin = None + retry_counter = settings.PLUGIN_RETRY + while not registered_sucessfull: + try: + # we are using the db so for migrations etc we need to try this block + self._init_plugins(blocked_plugin) + self._activate_plugins() + registered_sucessfull = True + except (OperationalError, ProgrammingError): + # Exception if the database has not been migrated yet + logger.info('Database not accessible while loading plugins') + break + except IntegrationPluginError as error: + logger.error(f'[PLUGIN] Encountered an error with {error.path}:\n{error.message}') + log_plugin_error({error.path: error.message}, 'load') + blocked_plugin = error.path # we will not try to load this app again + + # init apps without any integration plugins + self._clean_registry() + self._clean_installed_apps() + self._activate_plugins(force_reload=True) + + # we do not want to end in an endless loop + retry_counter -= 1 + if retry_counter <= 0: + 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') + + # now the loading will re-start up with init + + # remove maintenance + if not _maintenance: + set_maintenance_mode(False) + logger.info('Finished loading plugins') + + def unload_plugins(self): + """unload and deactivate all IntegrationPlugins""" + logger.info('Start unloading plugins') + # set maintanace mode + _maintenance = bool(get_maintenance_mode()) + if not _maintenance: + set_maintenance_mode(True) + + # remove all plugins from registry + self._clean_registry() + + # deactivate all integrations + self._deactivate_plugins() + + # remove maintenance + if not _maintenance: + set_maintenance_mode(False) + logger.info('Finished unloading plugins') + + def reload_plugins(self): + """safely reload IntegrationPlugins""" + # do not reload whe currently loading + if self.is_loading: + return + + logger.info('Start reloading plugins') + with maintenance_mode_on(): + self.unload_plugins() + self.load_plugins() + logger.info('Finished reloading plugins') + # endregion + + # region general plugin managment mechanisms + def collect_plugins(self): + """collect integration plugins from all possible ways of loading""" + self.plugin_modules = [] # clear + + # Collect plugins from paths + for plugin in settings.PLUGIN_DIRS: + modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True) + if modules: + [self.plugin_modules.append(item) for item in modules] + + # check if not running in testing mode and apps should be loaded from hooks + if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP): + # Collect plugins from setup entry points + for entry in metadata.entry_points().get('inventree_plugins', []): + plugin = entry.load() + plugin.is_package = True + self.plugin_modules.append(plugin) + + # Log collected plugins + logger.info(f'Collected {len(self.plugin_modules)} plugins!') + logger.info(", ".join([a.__module__ for a in self.plugin_modules])) + + def _init_plugins(self, disabled=None): + """initialise all found plugins + + :param disabled: loading path of disabled app, defaults to None + :type disabled: str, optional + :raises error: IntegrationPluginError + """ + from plugin.models import PluginConfig + + logger.info('Starting plugin initialisation') + # Initialize integration 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_key = slugify(plug_key) # keys are slugs! + try: + plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name) + except (OperationalError, ProgrammingError) as error: + # Exception if the database has not been migrated yet - check if test are running - raise if not + if not settings.PLUGIN_TESTING: + raise error + plugin_db_setting = None + + # always activate if testing + if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active): + # check if the plugin was blocked -> threw an error + if disabled: + # option1: package, option2: file-based + if (plugin.__name__ == disabled) or (plugin.__module__ == disabled): + # errors are bad so disable the plugin in the database + if not settings.PLUGIN_TESTING: + 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 + self.plugins_inactive[plug_key] = plugin_db_setting + continue # continue -> the plugin is not loaded + + # init 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}') + try: + plugin = plugin() + except Exception as error: + # log error and raise it -> disable plugin + get_plugin_error(error, do_raise=True, do_log=True, log_name='init') + + logger.info(f'Loaded integration plugin {plugin.slug}') + plugin.is_package = was_packaged + if plugin_db_setting: + plugin.pk = plugin_db_setting.pk + + # safe reference + self.plugins[plugin.slug] = plugin + else: + # save for later reference + self.plugins_inactive[plug_key] = plugin_db_setting + + def _activate_plugins(self, force_reload=False): + """run integration functions for all plugins + + :param force_reload: force reload base apps, defaults to False + :type force_reload: bool, optional + """ + # activate integrations + plugins = self.plugins.items() + logger.info(f'Found {len(plugins)} active plugins') + + self.activate_integration_globalsettings(plugins) + self.activate_integration_app(plugins, force_reload=force_reload) + + def _deactivate_plugins(self): + """run integration deactivation functions for all plugins""" + self.deactivate_integration_app() + self.deactivate_integration_globalsettings() + # endregion + + # region specific integrations + # region integration_globalsettings + def activate_integration_globalsettings(self, plugins): + from common.models import InvenTreeSetting + + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'): + logger.info('Registering IntegrationPlugin global settings') + for slug, plugin in plugins: + if plugin.mixin_enabled('globalsettings'): + plugin_setting = plugin.globalsettingspatterns + self.mixins_globalsettings[slug] = plugin_setting + + # Add to settings dir + InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting) + + def deactivate_integration_globalsettings(self): + from common.models import InvenTreeSetting + + # collect all settings + plugin_settings = {} + for _, plugin_setting in self.mixins_globalsettings.items(): + plugin_settings.update(plugin_setting) + + # remove settings + for setting in plugin_settings: + InvenTreeSetting.GLOBAL_SETTINGS.pop(setting) + + # clear cache + self.mixins_globalsettings = {} + # endregion + + # region integration_app + def activate_integration_app(self, plugins, force_reload=False): + """activate AppMixin plugins - add custom apps and reload + + :param plugins: list of IntegrationPlugins that should be installed + :type plugins: dict + :param force_reload: only reload base apps, defaults to False + :type force_reload: bool, optional + """ + from common.models import InvenTreeSetting + + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'): + logger.info('Registering IntegrationPlugin apps') + apps_changed = False + + # add them to the INSTALLED_APPS + for slug, plugin in plugins: + if plugin.mixin_enabled('app'): + plugin_path = self._get_plugin_path(plugin) + if plugin_path not in settings.INSTALLED_APPS: + settings.INSTALLED_APPS += [plugin_path] + self.installed_apps += [plugin_path] + apps_changed = True + + # if apps were changed or force loading base apps -> reload + if apps_changed or force_reload: + # first startup or force loading of base apps -> registry is prob false + if self.apps_loading or force_reload: + self.apps_loading = False + self._reload_apps(force_reload=True) + else: + self._reload_apps() + + # rediscover models/ admin sites + self._reregister_contrib_apps() + + # update urls - must be last as models must be registered for creating admin routes + self._update_urls() + + def _reregister_contrib_apps(self): + """fix reloading of contrib apps - models and admin + this is needed if plugins were loaded earlier and then reloaded as models and admins rely on imports + those register models and admin in their respective objects (e.g. admin.site for admin) + """ + for plugin_path in self.installed_apps: + try: + app_name = plugin_path.split('.')[-1] + app_config = apps.get_app_config(app_name) + except LookupError: + # the plugin was never loaded correctly + logger.debug(f'{app_name} App was not found during deregistering') + break + + # reload models if they were set + # models_module gets set if models were defined - even after multiple loads + # on a reload the models registery is empty but models_module is not + if app_config.models_module and len(app_config.models) == 0: + reload(app_config.models_module) + + # check for all models if they are registered with the site admin + model_not_reg = False + for model in app_config.get_models(): + if not admin.site.is_registered(model): + model_not_reg = True + + # reload admin if at least one model is not registered + # models are registered with admin in the 'admin.py' file - so we check + # if the app_config has an admin module before trying to laod it + if model_not_reg and hasattr(app_config.module, 'admin'): + reload(app_config.module.admin) + + def _get_plugin_path(self, plugin): + """parse plugin path + the input can be eiter: + - a local file / dir + - a package + """ + try: + # for local path plugins + plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts) + except ValueError: + # plugin is shipped as package + plugin_path = plugin.PLUGIN_NAME + return plugin_path + + def deactivate_integration_app(self): + """deactivate integration app - some magic required""" + # unregister models from admin + for plugin_path in self.installed_apps: + models = [] # the modelrefs need to be collected as poping an item in a iter is not welcomed + app_name = plugin_path.split('.')[-1] + try: + app_config = apps.get_app_config(app_name) + + # check all models + for model in app_config.get_models(): + # remove model from admin site + admin.site.unregister(model) + models += [model._meta.model_name] + except LookupError: + # if an error occurs the app was never loaded right -> so nothing to do anymore + logger.debug(f'{app_name} App was not found during deregistering') + break + + # unregister the models (yes, models are just kept in multilevel dicts) + for model in models: + # remove model from general registry + apps.all_models[plugin_path].pop(model) + + # clear the registry for that app + # so that the import trick will work on reloading the same plugin + # -> the registry is kept for the whole lifecycle + if models and app_name in apps.all_models: + apps.all_models.pop(app_name) + + # remove plugin from installed_apps + self._clean_installed_apps() + + # reset load flag and reload apps + settings.INTEGRATION_APPS_LOADED = False + self._reload_apps() + + # update urls to remove the apps from the site admin + self._update_urls() + + def _clean_installed_apps(self): + for plugin in self.installed_apps: + if plugin in settings.INSTALLED_APPS: + settings.INSTALLED_APPS.remove(plugin) + + self.installed_apps = [] + + def _clean_registry(self): + # remove all plugins from registry + self.plugins = {} + self.plugins_inactive = {} + + def _update_urls(self): + from InvenTree.urls import urlpatterns + from plugin.urls import get_plugin_urls + + for index, a in enumerate(urlpatterns): + if hasattr(a, 'app_name'): + if a.app_name == 'admin': + urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin') + elif a.app_name == 'plugin': + urlpatterns[index] = get_plugin_urls() + clear_url_caches() + + def _reload_apps(self, force_reload: bool = False): + self.is_loading = True # set flag to disable loop reloading + if force_reload: + # we can not use the built in functions as we need to brute force the registry + apps.app_configs = OrderedDict() + apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False + apps.clear_cache() + self._try_reload(apps.populate, settings.INSTALLED_APPS) + else: + self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS) + self.is_loading = False + + def _try_reload(self, cmd, *args, **kwargs): + """ + wrapper to try reloading the apps + throws an custom error that gets handled by the loading function + """ + try: + cmd(*args, **kwargs) + return True, [] + except Exception as error: + get_plugin_error(error, do_raise=True) + # endregion + # endregion + + +plugins = Plugins() diff --git a/InvenTree/plugin/samples/__init__.py b/InvenTree/plugin/samples/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/samples/integration/__init__.py b/InvenTree/plugin/samples/integration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/samples/integration/another_sample.py b/InvenTree/plugin/samples/integration/another_sample.py new file mode 100644 index 0000000000..9b3a3d8ec7 --- /dev/null +++ b/InvenTree/plugin/samples/integration/another_sample.py @@ -0,0 +1,19 @@ +"""sample implementation for IntegrationPlugin""" +from plugin import IntegrationPluginBase +from plugin.mixins import UrlsMixin + + +class NoIntegrationPlugin(IntegrationPluginBase): + """ + An basic integration plugin + """ + + PLUGIN_NAME = "NoIntegrationPlugin" + + +class WrongIntegrationPlugin(UrlsMixin, IntegrationPluginBase): + """ + An basic integration plugin + """ + + PLUGIN_NAME = "WrongIntegrationPlugin" diff --git a/InvenTree/plugin/samples/integration/broken_file.py b/InvenTree/plugin/samples/integration/broken_file.py new file mode 100644 index 0000000000..c575cfb623 --- /dev/null +++ b/InvenTree/plugin/samples/integration/broken_file.py @@ -0,0 +1,11 @@ +"""sample of a broken python file that will be ignored on import""" +from plugin import IntegrationPluginBase + + +class BrokenFileIntegrationPlugin(IntegrationPluginBase): + """ + An very broken integration plugin + """ + + +aaa = bb # noqa: F821 diff --git a/InvenTree/plugin/samples/integration/broken_sample.py b/InvenTree/plugin/samples/integration/broken_sample.py new file mode 100644 index 0000000000..8901d83dfd --- /dev/null +++ b/InvenTree/plugin/samples/integration/broken_sample.py @@ -0,0 +1,16 @@ +"""sample of a broken integration plugin""" +from plugin import IntegrationPluginBase + + +class BrokenIntegrationPlugin(IntegrationPluginBase): + """ + An very broken integration plugin + """ + PLUGIN_NAME = 'Test' + PLUGIN_TITLE = 'Broken Plugin' + PLUGIN_SLUG = 'broken' + + def __init__(self): + super().__init__() + + raise KeyError('This is a dummy error') diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py new file mode 100644 index 0000000000..d7321f8a88 --- /dev/null +++ b/InvenTree/plugin/samples/integration/sample.py @@ -0,0 +1,48 @@ +"""sample implementations for IntegrationPlugin""" +from plugin import IntegrationPluginBase +from plugin.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin + +from django.http import HttpResponse +from django.utils.translation import ugettext_lazy as _ +from django.conf.urls import url, include + + +class SampleIntegrationPlugin(AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase): + """ + An full integration plugin + """ + + PLUGIN_NAME = "SampleIntegrationPlugin" + PLUGIN_SLUG = "sample" + PLUGIN_TITLE = "Sample Plugin" + + NAVIGATION_TAB_NAME = "Sample Nav" + NAVIGATION_TAB_ICON = 'fas fa-plus' + + def view_test(self, request): + """very basic view""" + return HttpResponse(f'Hi there {request.user.username} this works') + + def setup_urls(self): + he_urls = [ + url(r'^he/', self.view_test, name='he'), + url(r'^ha/', self.view_test, name='ha'), + ] + + return [ + url(r'^hi/', self.view_test, name='hi'), + url(r'^ho/', include(he_urls), name='ho'), + ] + + SETTINGS = { + 'PO_FUNCTION_ENABLE': { + 'name': _('Enable PO'), + 'description': _('Enable PO functionality in InvenTree interface'), + 'default': True, + 'validator': bool, + }, + } + + NAVIGATION = [ + {'name': 'SampleIntegration', 'link': 'plugin:sample:hi'}, + ] diff --git a/InvenTree/plugin/samples/integration/test_samples_integration.py b/InvenTree/plugin/samples/integration/test_samples_integration.py new file mode 100644 index 0000000000..733e443638 --- /dev/null +++ b/InvenTree/plugin/samples/integration/test_samples_integration.py @@ -0,0 +1,21 @@ +""" Unit tests for action plugins """ + +from django.test import TestCase +from django.contrib.auth import get_user_model + + +class SampleIntegrationPluginTests(TestCase): + """ Tests for SampleIntegrationPlugin """ + + def setUp(self): + # Create a user for auth + user = get_user_model() + user.objects.create_user('testuser', 'test@testing.com', 'password') + + self.client.login(username='testuser', password='password') + + def test_view(self): + """check the function of the custom sample plugin """ + response = self.client.get('/plugin/sample/ho/he/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'Hi there testuser this works') diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py new file mode 100644 index 0000000000..e25e253498 --- /dev/null +++ b/InvenTree/plugin/serializers.py @@ -0,0 +1,119 @@ +""" +JSON serializers for Stock app +""" + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import os +import subprocess + +from django.core.exceptions import ValidationError +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ + +from rest_framework import serializers + +from plugin.models import PluginConfig + + +class PluginConfigSerializer(serializers.ModelSerializer): + """ Serializer for a PluginConfig: + """ + + meta = serializers.DictField(read_only=True) + mixins = serializers.DictField(read_only=True) + + class Meta: + model = PluginConfig + fields = [ + 'key', + 'name', + 'active', + 'meta', + 'mixins', + ] + + +class PluginConfigInstallSerializer(serializers.Serializer): + """ + Serializer for installing a new plugin + """ + + url = serializers.CharField( + required=False, + allow_blank=True, + label=_('Source URL'), + help_text=_('Source for the package - this can be a custom registry or a VCS path') + ) + packagename = serializers.CharField( + required=False, + allow_blank=True, + label=_('Package Name'), + help_text=_('Name for the Plugin Package - can also contain a version indicator'), + ) + confirm = serializers.BooleanField( + label=_('Confirm plugin installation'), + help_text=_('This will install this plugin now into the current instance. The instance will go into maintenance.') + ) + + class Meta: + fields = [ + 'url', + 'packagename', + 'confirm', + ] + + def validate(self, data): + super().validate(data) + + # check the base requirements are met + if not data.get('confirm'): + raise ValidationError({'confirm': _('Installation not confirmed')}) + if (not data.get('url')) and (not data.get('packagename')): + msg = _('Either packagenmae of url must be provided') + raise ValidationError({'url': msg, 'packagename': msg}) + + return data + + def save(self): + data = self.validated_data + + packagename = data.get('packagename', '') + url = data.get('url', '') + + # build up the command + command = 'python -m pip install'.split() + + if url: + # use custom registration / VCS + if True in [identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn', ]]: + # using a VCS provider + if packagename: + command.append(f'{packagename}@{url}') + else: + command.append(url) + else: + # using a custom package repositories + command.append('-i') + command.append(url) + command.append(packagename) + + elif packagename: + # use pypi + command.append(packagename) + + ret = {'command': ' '.join(command)} + # execute pypi + try: + result = subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)) + ret['result'] = str(result, 'utf-8') + ret['success'] = True + except subprocess.CalledProcessError as error: + ret['result'] = str(error.output, 'utf-8') + ret['error'] = True + + # register plugins + # TODO + + return ret diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py new file mode 100644 index 0000000000..1b4b269844 --- /dev/null +++ b/InvenTree/plugin/templatetags/plugin_extras.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +""" This module provides template tags for handeling plugins +""" +from django.conf import settings as djangosettings +from django import template +from django.urls import reverse + +from common.models import InvenTreeSetting +from plugin import plugin_reg + + +register = template.Library() + + +@register.simple_tag() +def plugin_list(*args, **kwargs): + """ Return a list of all installed integration plugins """ + return plugin_reg.plugins + + +@register.simple_tag() +def inactive_plugin_list(*args, **kwargs): + """ Return a list of all inactive integration plugins """ + return plugin_reg.plugins_inactive + + +@register.simple_tag() +def plugin_globalsettings(plugin, *args, **kwargs): + """ Return a list of all global settings for a plugin """ + return plugin_reg.mixins_globalsettings.get(plugin) + + +@register.simple_tag() +def mixin_enabled(plugin, key, *args, **kwargs): + """ Return if the mixin is existant and configured in the plugin """ + return plugin.mixin_enabled(key) + + +@register.simple_tag() +def navigation_enabled(*args, **kwargs): + """Return if plugin navigation is enabled""" + if djangosettings.PLUGIN_TESTING: + return True + return InvenTreeSetting.get_setting('ENABLE_PLUGINS_NAVIGATION') + + +@register.simple_tag() +def safe_url(view_name, *args, **kwargs): + """ safe lookup for urls """ + try: + return reverse(view_name, args=args, kwargs=kwargs) + except: + return None + + +@register.simple_tag() +def plugin_errors(*args, **kwargs): + """Return all plugin errors""" + return plugin_reg.errors diff --git a/InvenTree/plugin/test_action.py b/InvenTree/plugin/test_action.py new file mode 100644 index 0000000000..2a9e4a9a37 --- /dev/null +++ b/InvenTree/plugin/test_action.py @@ -0,0 +1,61 @@ +""" Unit tests for action plugins """ + +from django.test import TestCase + +from plugin.action import ActionPlugin + + +class ActionPluginTests(TestCase): + """ Tests for ActionPlugin """ + ACTION_RETURN = 'a action was performed' + + def setUp(self): + self.plugin = ActionPlugin('user') + + class TestActionPlugin(ActionPlugin): + """a action plugin""" + ACTION_NAME = 'abc123' + + def perform_action(self): + return ActionPluginTests.ACTION_RETURN + 'action' + + def get_result(self): + return ActionPluginTests.ACTION_RETURN + 'result' + + def get_info(self): + return ActionPluginTests.ACTION_RETURN + 'info' + + self.action_plugin = TestActionPlugin('user') + + class NameActionPlugin(ActionPlugin): + PLUGIN_NAME = 'Aplugin' + + self.action_name = NameActionPlugin('user') + + 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', + }) diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py new file mode 100644 index 0000000000..0bb5f7789b --- /dev/null +++ b/InvenTree/plugin/test_api.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.urls import reverse + +from InvenTree.api_tester import InvenTreeAPITestCase + + +class PluginDetailAPITest(InvenTreeAPITestCase): + """ + Tests the plugin AP I endpoints + """ + + roles = [ + 'admin.add', + 'admin.view', + 'admin.change', + 'admin.delete', + ] + + def setUp(self): + self.MSG_NO_PKG = 'Either packagenmae of url must be provided' + + self.PKG_NAME = 'minimal' + super().setUp() + + def test_plugin_install(self): + """ + Test the plugin install command + """ + url = reverse('api-plugin-install') + + # valid - Pypi + data = self.post(url, { + 'confirm': True, + 'packagename': self.PKG_NAME + }, expected_code=201).data + + self.assertEqual(data['success'], True) + + # invalid tries + # no input + self.post(url, {}, expected_code=400) + + # no package info + data = self.post(url, { + 'confirm': True, + }, expected_code=400).data + self.assertEqual(data['url'][0].title().upper(), self.MSG_NO_PKG.upper()) + self.assertEqual(data['packagename'][0].title().upper(), self.MSG_NO_PKG.upper()) + + # not confirmed + self.post(url, { + 'packagename': self.PKG_NAME + }, expected_code=400).data + data = self.post(url, { + 'packagename': self.PKG_NAME, + 'confirm': False, + }, expected_code=400).data + self.assertEqual(data['confirm'][0].title().upper(), 'Installation not confirmed'.upper()) + + def test_admin_action(self): + """ + Test the PluginConfig action commands + """ + from plugin.models import PluginConfig + from plugin import plugin_reg + + url = reverse('admin:plugin_pluginconfig_changelist') + fixtures = PluginConfig.objects.all() + + # check if plugins were registered -> in some test setups the startup has no db access + if not fixtures: + plugin_reg.reload_plugins() + fixtures = PluginConfig.objects.all() + + print([str(a) for a in fixtures]) + fixtures = fixtures[0:1] + # deactivate plugin + response = self.client.post(url, { + 'action': 'plugin_deactivate', + 'index': 0, + '_selected_action': [f.pk for f in fixtures], + }, follow=True) + self.assertEqual(response.status_code, 200) + + # deactivate plugin - deactivate again -> nothing will hapen but the nothing 'changed' function is triggered + response = self.client.post(url, { + 'action': 'plugin_deactivate', + 'index': 0, + '_selected_action': [f.pk for f in fixtures], + }, follow=True) + self.assertEqual(response.status_code, 200) + + # activate plugin + response = self.client.post(url, { + 'action': 'plugin_activate', + 'index': 0, + '_selected_action': [f.pk for f in fixtures], + }, follow=True) + self.assertEqual(response.status_code, 200) + + # activate everything + fixtures = PluginConfig.objects.all() + response = self.client.post(url, { + 'action': 'plugin_activate', + 'index': 0, + '_selected_action': [f.pk for f in fixtures], + }, follow=True) + self.assertEqual(response.status_code, 200) + + fixtures = PluginConfig.objects.filter(active=True) + # save to deactivate a plugin + response = self.client.post(reverse('admin:plugin_pluginconfig_change', args=(fixtures.first().pk, )), { + '_save': 'Save', + }, follow=True) + self.assertEqual(response.status_code, 200) diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py new file mode 100644 index 0000000000..df80016dc8 --- /dev/null +++ b/InvenTree/plugin/test_integration.py @@ -0,0 +1,216 @@ +""" Unit tests for integration plugins """ + +from django.test import TestCase +from django.conf import settings +from django.conf.urls import url, include +from django.contrib.auth import get_user_model + +from datetime import datetime + +from plugin import IntegrationPluginBase +from plugin.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin +from plugin.urls import PLUGIN_BASE + + +class BaseMixinDefinition: + def test_mixin_name(self): + # mixin name + self.assertEqual(self.mixin.registered_mixins[0]['key'], self.MIXIN_NAME) + # human name + self.assertEqual(self.mixin.registered_mixins[0]['human_name'], self.MIXIN_HUMAN_NAME) + + +class GlobalSettingsMixinTest(BaseMixinDefinition, TestCase): + MIXIN_HUMAN_NAME = 'Global settings' + MIXIN_NAME = 'globalsettings' + MIXIN_ENABLE_CHECK = 'has_globalsettings' + + TEST_SETTINGS = {'SETTING1': {'default': '123', }} + + def setUp(self): + class SettingsCls(GlobalSettingsMixin, IntegrationPluginBase): + GLOBALSETTINGS = self.TEST_SETTINGS + self.mixin = SettingsCls() + + class NoSettingsCls(GlobalSettingsMixin, IntegrationPluginBase): + pass + self.mixin_nothing = NoSettingsCls() + + user = get_user_model() + self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password') + self.test_user.is_staff = True + + def test_function(self): + # settings variable + self.assertEqual(self.mixin.globalsettings, self.TEST_SETTINGS) + + # settings pattern + target_pattern = {f'PLUGIN_{self.mixin.slug.upper()}_{key}': value for key, value in self.mixin.globalsettings.items()} + self.assertEqual(self.mixin.globalsettingspatterns, target_pattern) + + # no settings + self.assertIsNone(self.mixin_nothing.globalsettings) + self.assertIsNone(self.mixin_nothing.globalsettingspatterns) + + # calling settings + # not existing + self.assertEqual(self.mixin.get_globalsetting('ABCD'), '') + self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '') + # right setting + self.mixin.set_globalsetting('SETTING1', '12345', self.test_user) + self.assertEqual(self.mixin.get_globalsetting('SETTING1'), '12345') + # no setting + self.assertEqual(self.mixin_nothing.get_globalsetting(''), '') + + +class UrlsMixinTest(BaseMixinDefinition, TestCase): + MIXIN_HUMAN_NAME = 'URLs' + MIXIN_NAME = 'urls' + MIXIN_ENABLE_CHECK = 'has_urls' + + def setUp(self): + class UrlsCls(UrlsMixin, IntegrationPluginBase): + def test(): + return 'ccc' + URLS = [url('testpath', test, name='test'), ] + self.mixin = UrlsCls() + + class NoUrlsCls(UrlsMixin, IntegrationPluginBase): + pass + self.mixin_nothing = NoUrlsCls() + + def test_function(self): + plg_name = self.mixin.plugin_name() + + # base_url + target_url = f'{PLUGIN_BASE}/{plg_name}/' + self.assertEqual(self.mixin.base_url, target_url) + + # urlpattern + target_pattern = url(f'^{plg_name}/', include((self.mixin.urls, plg_name)), name=plg_name) + self.assertEqual(self.mixin.urlpatterns.reverse_dict, target_pattern.reverse_dict) + + # resolve the view + self.assertEqual(self.mixin.urlpatterns.resolve('/testpath').func(), 'ccc') + self.assertEqual(self.mixin.urlpatterns.reverse('test'), 'testpath') + + # no url + self.assertIsNone(self.mixin_nothing.urls) + self.assertIsNone(self.mixin_nothing.urlpatterns) + + # internal name + self.assertEqual(self.mixin.internal_name, f'plugin:{self.mixin.slug}:') + + +class AppMixinTest(BaseMixinDefinition, TestCase): + MIXIN_HUMAN_NAME = 'App registration' + MIXIN_NAME = 'app' + MIXIN_ENABLE_CHECK = 'has_app' + + def setUp(self): + class TestCls(AppMixin, IntegrationPluginBase): + pass + self.mixin = TestCls() + + def test_function(self): + # test that this plugin is in settings + self.assertIn('plugin.samples.integration', settings.INSTALLED_APPS) + + +class NavigationMixinTest(BaseMixinDefinition, TestCase): + MIXIN_HUMAN_NAME = 'Navigation Links' + MIXIN_NAME = 'navigation' + MIXIN_ENABLE_CHECK = 'has_naviation' + + def setUp(self): + class NavigationCls(NavigationMixin, IntegrationPluginBase): + NAVIGATION = [ + {'name': 'aa', 'link': 'plugin:test:test_view'}, + ] + NAVIGATION_TAB_NAME = 'abcd1' + self.mixin = NavigationCls() + + class NothingNavigationCls(NavigationMixin, IntegrationPluginBase): + 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, '') + + +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() + + 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') + + # 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') diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py new file mode 100644 index 0000000000..3e0a1967db --- /dev/null +++ b/InvenTree/plugin/test_plugin.py @@ -0,0 +1,92 @@ +""" Unit tests for plugins """ + +from django.test import TestCase + +import plugin.plugin +import plugin.integration +from plugin.samples.integration.sample import SampleIntegrationPlugin +from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin +# from plugin.plugins import load_action_plugins, load_barcode_plugins +import plugin.templatetags.plugin_extras as plugin_tags +from plugin import plugin_reg + + +class InvenTreePluginTests(TestCase): + """ Tests for InvenTreePlugin """ + def setUp(self): + self.plugin = plugin.plugin.InvenTreePlugin() + + class NamedPlugin(plugin.plugin.InvenTreePlugin): + """a named plugin""" + PLUGIN_NAME = 'abc123' + + self.named_plugin = NamedPlugin() + + def test_basic_plugin_init(self): + """check if a basic plugin intis""" + self.assertEqual(self.plugin.PLUGIN_NAME, '') + self.assertEqual(self.plugin.plugin_name(), '') + + def test_basic_plugin_name(self): + """check if the name of a basic plugin can be set""" + self.assertEqual(self.named_plugin.PLUGIN_NAME, 'abc123') + self.assertEqual(self.named_plugin.plugin_name(), 'abc123') + + +class PluginIntegrationTests(TestCase): + """ Tests for general plugin functions """ + + def test_plugin_loading(self): + """check if plugins load as expected""" + # plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()] # TODO refactor barcode plugin to support standard loading + # plugin_names_action = [a().plugin_name() for a in load_action_plugins()] # TODO refactor action plugin to support standard loading + + # self.assertEqual(plugin_names_action, '') + # self.assertEqual(plugin_names_barcode, '') + + # TODO remove test once loading is moved + + +class PluginTagTests(TestCase): + """ Tests for the plugin extras """ + + def setUp(self): + self.sample = SampleIntegrationPlugin() + self.plugin_no = NoIntegrationPlugin() + self.plugin_wrong = WrongIntegrationPlugin() + + def test_tag_plugin_list(self): + """test that all plugins are listed""" + self.assertEqual(plugin_tags.plugin_list(), plugin_reg.plugins) + + def test_tag_incative_plugin_list(self): + """test that all inactive plugins are listed""" + self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_reg.plugins_inactive) + + def test_tag_plugin_globalsettings(self): + """check all plugins are listed""" + self.assertEqual( + plugin_tags.plugin_globalsettings(self.sample), + plugin_reg.mixins_globalsettings.get(self.sample) + ) + + def test_tag_mixin_enabled(self): + """check that mixin enabled functions work""" + key = 'urls' + # mixin enabled + self.assertEqual(plugin_tags.mixin_enabled(self.sample, key), True) + # mixin not enabled + self.assertEqual(plugin_tags.mixin_enabled(self.plugin_wrong, key), False) + # mxixn not existing + self.assertEqual(plugin_tags.mixin_enabled(self.plugin_no, key), False) + + def test_tag_safe_url(self): + """test that the safe url tag works expected""" + # right url + self.assertEqual(plugin_tags.safe_url('api-plugin-install'), '/api/plugin/install/') + # wrong url + self.assertEqual(plugin_tags.safe_url('indexas'), None) + + def test_tag_plugin_errors(self): + """test that all errors are listed""" + self.assertEqual(plugin_tags.plugin_errors(), plugin_reg.errors) diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py new file mode 100644 index 0000000000..419dce5a88 --- /dev/null +++ b/InvenTree/plugin/urls.py @@ -0,0 +1,18 @@ +""" +URL lookup for plugin app +""" +from django.conf.urls import url, include + +from plugin import plugin_reg + + +PLUGIN_BASE = 'plugin' # Constant for links + + +def get_plugin_urls(): + """returns a urlpattern that can be integrated into the global urls""" + urls = [] + for plugin in plugin_reg.plugins.values(): + if plugin.mixin_enabled('urls'): + urls.append(plugin.urlpatterns) + return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin'))) diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugins/plugins.py deleted file mode 100644 index f6b68112bc..0000000000 --- a/InvenTree/plugins/plugins.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: utf-8 -*- - -import inspect -import importlib -import pkgutil -import logging - -# Action plugins -import plugins.action as action -from plugins.action.action import ActionPlugin - - -logger = logging.getLogger("inventree") - - -def iter_namespace(pkg): - - return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".") - - -def get_modules(pkg): - # Return all modules in a given package - return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)] - - -def get_classes(module): - # Return all classes in a given module - return inspect.getmembers(module, inspect.isclass) - - -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 - """ - - plugins = [] - - modules = get_modules(pkg) - - # Iterate through each module in the package - for mod in modules: - # Iterate through each class in the module - for item in get_classes(mod): - plugin = item[1] - if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME: - plugins.append(plugin) - - return plugins - - -def load_action_plugins(): - """ - Return a list of all registered action plugins - """ - - logger.debug("Loading action plugins") - - plugins = get_plugins(action, ActionPlugin) - - if len(plugins) > 0: - logger.info("Discovered {n} action plugins:".format(n=len(plugins))) - - for ap in plugins: - logger.debug(" - {ap}".format(ap=ap.PLUGIN_NAME)) - - return plugins diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 760a18c72c..8a2d2fa051 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -86,17 +86,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): return self.serializer_class(*args, **kwargs) - def perform_destroy(self, instance): - """ - Instead of "deleting" the StockItem - (which may take a long time) - we instead schedule it for deletion at a later date. - - The background worker will delete these in the future - """ - - instance.mark_for_deletion() - class StockItemSerialize(generics.CreateAPIView): """ @@ -623,9 +612,6 @@ class StockList(generics.ListCreateAPIView): queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset) - # Do not expose StockItem objects which are scheduled for deletion - queryset = queryset.filter(scheduled_for_deletion=False) - return queryset def filter_queryset(self, queryset): diff --git a/InvenTree/stock/migrations/0071_auto_20211205_1733.py b/InvenTree/stock/migrations/0071_auto_20211205_1733.py new file mode 100644 index 0000000000..e069f77a20 --- /dev/null +++ b/InvenTree/stock/migrations/0071_auto_20211205_1733.py @@ -0,0 +1,47 @@ +# Generated by Django 3.2.5 on 2021-12-05 06:33 + +from django.db import migrations + +import logging + + +logger = logging.getLogger('inventree') + + +def delete_scheduled(apps, schema_editor): + """ + Delete all stock items which are marked as 'scheduled_for_deletion'. + + The issue that this field was addressing has now been fixed, + and so we can all move on with our lives... + """ + + StockItem = apps.get_model('stock', 'stockitem') + + items = StockItem.objects.filter(scheduled_for_deletion=True) + + logger.info(f"Removing {items.count()} stock items scheduled for deletion") + + items.delete() + + Task = apps.get_model('django_q', 'schedule') + + Task.objects.filter(func='stock.tasks.delete_old_stock_items').delete() + + +def reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0070_auto_20211128_0151'), + ] + + operations = [ + migrations.RunPython( + delete_scheduled, + reverse_code=reverse, + ) + ] diff --git a/InvenTree/stock/migrations/0072_remove_stockitem_scheduled_for_deletion.py b/InvenTree/stock/migrations/0072_remove_stockitem_scheduled_for_deletion.py new file mode 100644 index 0000000000..0db2141299 --- /dev/null +++ b/InvenTree/stock/migrations/0072_remove_stockitem_scheduled_for_deletion.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.5 on 2021-12-05 06:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0071_auto_20211205_1733'), + ] + + operations = [ + migrations.RemoveField( + model_name='stockitem', + name='scheduled_for_deletion', + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 0aa63687c9..7f385e7136 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -212,18 +212,12 @@ class StockItem(MPTTModel): belongs_to=None, customer=None, is_building=False, - status__in=StockStatus.AVAILABLE_CODES, - scheduled_for_deletion=False, + status__in=StockStatus.AVAILABLE_CODES ) # A query filter which can be used to filter StockItem objects which have expired EXPIRED_FILTER = IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=datetime.now().date()) - def mark_for_deletion(self): - - self.scheduled_for_deletion = True - self.save() - def update_serial_number(self): """ Update the 'serial_int' field, to be an integer representation of the serial number. @@ -615,12 +609,6 @@ class StockItem(MPTTModel): help_text=_('Select Owner'), related_name='stock_items') - scheduled_for_deletion = models.BooleanField( - default=False, - verbose_name=_('Scheduled for deletion'), - help_text=_('This StockItem will be deleted by the background worker'), - ) - def is_stale(self): """ Returns True if this Stock item is "stale". @@ -1327,7 +1315,7 @@ class StockItem(MPTTModel): self.quantity = quantity if quantity == 0 and self.delete_on_deplete and self.can_delete(): - self.mark_for_deletion() + self.delete() return False else: diff --git a/InvenTree/stock/tasks.py b/InvenTree/stock/tasks.py index 9fbd875384..a2b5079b33 100644 --- a/InvenTree/stock/tasks.py +++ b/InvenTree/stock/tasks.py @@ -1,35 +1,2 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals - -import logging - -from django.core.exceptions import AppRegistryNotReady - - -logger = logging.getLogger('inventree') - - -def delete_old_stock_items(): - """ - This function removes StockItem objects which have been marked for deletion. - - Bulk "delete" operations for database entries with foreign-key relationships - can be pretty expensive, and thus can "block" the UI for a period of time. - - Thus, instead of immediately deleting multiple StockItems, some UI actions - simply mark each StockItem as "scheduled for deletion". - - The background worker then manually deletes these at a later stage - """ - - try: - from stock.models import StockItem - except AppRegistryNotReady: - logger.info("Could not delete scheduled StockItems - AppRegistryNotReady") - return - - items = StockItem.objects.filter(scheduled_for_deletion=True) - - if items.count() > 0: - logger.info(f"Removing {items.count()} StockItem objects scheduled for deletion") - items.delete() diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 2c1b250e5f..522468a740 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -18,7 +18,6 @@ from InvenTree.api_tester import InvenTreeAPITestCase from common.models import InvenTreeSetting from .models import StockItem, StockLocation -from .tasks import delete_old_stock_items class StockAPITestCase(InvenTreeAPITestCase): @@ -593,11 +592,7 @@ class StockItemDeletionTest(StockAPITestCase): def test_delete(self): - # Check there are no stock items scheduled for deletion - self.assertEqual( - StockItem.objects.filter(scheduled_for_deletion=True).count(), - 0 - ) + n = StockItem.objects.count() # Create and then delete a bunch of stock items for idx in range(10): @@ -615,9 +610,7 @@ class StockItemDeletionTest(StockAPITestCase): pk = response.data['pk'] - item = StockItem.objects.get(pk=pk) - - self.assertFalse(item.scheduled_for_deletion) + self.assertEqual(StockItem.objects.count(), n + 1) # Request deletion via the API self.delete( @@ -625,19 +618,7 @@ class StockItemDeletionTest(StockAPITestCase): expected_code=204 ) - # There should be 100x StockItem objects marked for deletion - self.assertEqual( - StockItem.objects.filter(scheduled_for_deletion=True).count(), - 10 - ) - - # Perform the actual delete (will take some time) - delete_old_stock_items() - - self.assertEqual( - StockItem.objects.filter(scheduled_for_deletion=True).count(), - 0 - ) + self.assertEqual(StockItem.objects.count(), n) class StockTestResultTest(StockAPITestCase): diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index cb1bd406bb..40e6561926 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -332,8 +332,6 @@ class StockTest(TestCase): w1 = StockItem.objects.get(pk=100) w2 = StockItem.objects.get(pk=101) - self.assertFalse(w2.scheduled_for_deletion) - # Take 25 units from w1 (there are only 10 in stock) w1.take_stock(30, None, notes='Took 30') @@ -344,15 +342,6 @@ class StockTest(TestCase): # Take 25 units from w2 (will be deleted) w2.take_stock(30, None, notes='Took 30') - # w2 should now be marked for future deletion - w2 = StockItem.objects.get(pk=101) - self.assertTrue(w2.scheduled_for_deletion) - - from stock.tasks import delete_old_stock_items - - # Now run the "background task" to delete these stock items - delete_old_stock_items() - # This StockItem should now have been deleted with self.assertRaises(StockItem.DoesNotExist): w2 = StockItem.objects.get(pk=101) diff --git a/InvenTree/templates/503.html b/InvenTree/templates/503.html new file mode 100644 index 0000000000..fbee60c694 --- /dev/null +++ b/InvenTree/templates/503.html @@ -0,0 +1,73 @@ +{% extends "skeleton.html" %} +{% load static %} +{% load i18n %} + +{% block head %} + +{% endblock %} + +{% block page_title %} +{% trans 'Site is in Maintenance' %} +{% endblock %} + +{% block body_class %}login-screen{% endblock %} + +{% block body %} + +
+
+ +
+
+ +
+ + + + + {% block extra_body %} + {% endblock %} +
+{% endblock %} + +{% block js_base %} + +{% endblock %} + + diff --git a/InvenTree/templates/InvenTree/settings/mixins/settings.html b/InvenTree/templates/InvenTree/settings/mixins/settings.html new file mode 100644 index 0000000000..3c87da8678 --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/mixins/settings.html @@ -0,0 +1,13 @@ +{% load i18n %} +{% load plugin_extras %} + +

{% trans "Settings" %}

+{% plugin_globalsettings plugin_key as plugin_settings %} + + + + {% for setting in plugin_settings %} + {% include "InvenTree/settings/setting.html" with key=setting%} + {% endfor %} + +
\ No newline at end of file diff --git a/InvenTree/templates/InvenTree/settings/mixins/urls.html b/InvenTree/templates/InvenTree/settings/mixins/urls.html new file mode 100644 index 0000000000..1f317b5dc0 --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/mixins/urls.html @@ -0,0 +1,25 @@ +{% load i18n %} +{% load inventree_extras %} + +

{% trans "URLs" %}

+{% define plugin.base_url as base %} +

{% blocktrans %}The Base-URL for this plugin is {{ base }}.{% endblocktrans %}

+ + + + + + + + + + + {% for key, entry in plugin.urlpatterns.reverse_dict.items %}{% if key %} + + + + + + {% endif %}{% endfor %} + +
{% trans "Name" %}{% trans "URL" %}
{{key}}{{entry.1}}{% trans 'open in new tab' %}
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html new file mode 100644 index 0000000000..e9f33bedaf --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/plugin.html @@ -0,0 +1,146 @@ +{% extends "panel.html" %} +{% load i18n %} +{% load inventree_extras %} +{% load plugin_extras %} + +{% block label %}plugin{% endblock %} + + +{% block heading %} +{% trans "Plugin Settings" %} +{% endblock %} + +{% block content %} + +
+ {% trans "Changing the settings below require you to immediatly restart InvenTree. Do not change this while under active usage." %} +
+ +
+ + + {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %} + {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %} + {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_GLOBALSETTING"%} + {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%} + +
+
+ +
+
+

{% trans "Plugin list" %}

+ {% include "spacer.html" %} +
+ {% url 'admin:plugin_pluginconfig_changelist' as url %} + {% include "admin_button.html" with url=url %} + +
+
+
+ +
+ + + + + + + + + + + + + {% plugin_list as pl_list %} + {% for plugin_key, plugin in pl_list.items %} + {% mixin_enabled plugin 'urls' as urls %} + {% mixin_enabled plugin 'settings' as settings %} + + + + + + + + + {% endfor %} + + {% inactive_plugin_list as in_pl_list %} + {% if in_pl_list %} + + + {% for plugin_key, plugin in in_pl_list.items %} + + + + + + {% endfor %} + {% endif %} + +
{% trans "Admin" %}{% trans "Name" %}{% trans "Author" %}{% trans "Date" %}{% trans "Version" %}
+ {% if user.is_staff and perms.plugin.change_pluginconfig %} + {% url 'admin:plugin_pluginconfig_change' plugin.pk as url %} + {% include "admin_button.html" with url=url %} + {% endif %} + {{ plugin.human_name }} - {{plugin_key}} + {% define plugin.registered_mixins as mixin_list %} + + {% if mixin_list %} + {% for mixin in mixin_list %} + + {% blocktrans with name=mixin.human_name %}has {{name}}{% endblocktrans %} + + {% endfor %} + {% endif %} + + {% if plugin.website %} + + {% endif %} + {{ plugin.author }}{{ plugin.pub_date }}{% if plugin.version %}{{ plugin.version }}{% endif %}
{% trans 'Inactive plugins' %}
+ {% if user.is_staff and perms.plugin.change_pluginconfig %} + {% url 'admin:plugin_pluginconfig_change' plugin.pk as url %} + {% include "admin_button.html" with url=url %} + {% endif %} + {{plugin.name}} - {{plugin.key}}
+
+ + +{% plugin_errors as pl_errors %} +{% if pl_errors %} +
+
+

{% trans "Plugin Error Stack" %}

+ {% include "spacer.html" %} +
+
+ +
+ + + + + + + + + + + {% for stage, errors in pl_errors.items %} + {% for error_detail in errors %} + {% for name, message in error_detail.items %} + + + + + + {% endfor %} + {% endfor %} + {% endfor %} + +
{% trans "Stage" %}{% trans "Name" %}{% trans "Message" %}
{{ stage }}{{ name }}{{ message }}
+
+{% endif %} + +{% endblock %} diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html new file mode 100644 index 0000000000..e3b1f18046 --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html @@ -0,0 +1,137 @@ +{% extends "panel.html" %} +{% load i18n %} +{% load inventree_extras %} +{% load plugin_extras %} + +{% block label %}plugin-{{plugin_key}}{% endblock %} + + +{% block heading %} +{% blocktrans with name=plugin.human_name %}Plugin details for {{name}}{% endblocktrans %} +{% endblock %} + +{% block content %} + +
+
+

{% trans "Plugin information" %}

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if plugin.website %} + + + + + + {% endif %} + {% if plugin.license %} + + + + + + {% endif %} +
{% trans "Name" %}{{ plugin.human_name }}{% include "clip.html" %}
{% trans "Author" %}{{ plugin.author }}{% include "clip.html" %}
{% trans "Description" %}{{ plugin.description }}{% include "clip.html" %}
{% trans "Date" %}{{ plugin.pub_date }}{% include "clip.html" %}
{% trans "Version" %} + {% if plugin.version %} + {{ plugin.version }}{% include "clip.html" %} + {% else %} + {% trans 'no version information supplied' %} + {% endif %} +
{% trans "Website" %}{{ plugin.website }}{% include "clip.html" %}
{% trans "License" %}{{ plugin.license }}{% include "clip.html" %}
+
+ + {% if plugin.is_package == False %} +

{% trans 'The code information is pulled from the latest git commit for this plugin. It might not reflect official version numbers or information but the actual code running.' %}

+ {% endif %} +
+
+

{% trans "Package information" %}

+
+ + + + + + + + + + + + + {% if plugin.is_package == False %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + + + +
{% trans "Installation method" %} + {% if plugin.is_package %} + {% trans "This plugin was installed as a package" %} + {% else %} + {% trans "This plugin was found in a local InvenTree path" %} + {% endif %} +
{% trans "Installation path" %}{{ plugin.package_path }}
{% trans "Commit Author" %}{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %}
{% trans "Commit Date" %}{{ plugin.package.date }}{% include "clip.html" %}
{% trans "Commit Hash" %}{{ plugin.package.hash }}{% include "clip.html" %}
{% trans "Commit Message" %}{{ plugin.package.message }}{% include "clip.html" %}
{% trans "Sign Status" %}{% if plugin.package.verified %}{{ plugin.package.verified }}: {% endif%}{{ plugin.sign_state.msg }}
{% trans "Sign Key" %}{{ plugin.package.key }}{% include "clip.html" %}
+
+
+
+ +{% mixin_enabled plugin 'globalsettings' as globalsettings %} +{% if globalsettings %} + {% include 'InvenTree/settings/mixins/settings.html' %} +{% endif %} + +{% mixin_enabled plugin 'urls' as urls %} +{% if urls %} + {% include 'InvenTree/settings/mixins/urls.html' %} +{% endif %} + +{% endblock %} diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index d7f5014b9f..cb87c6765b 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -3,6 +3,7 @@ {% load i18n %} {% load static %} {% load inventree_extras %} +{% load plugin_extras %} {% block breadcrumb_list %} {% endblock %} @@ -38,6 +39,14 @@ {% include "InvenTree/settings/build.html" %} {% include "InvenTree/settings/po.html" %} {% include "InvenTree/settings/so.html" %} +{% include "InvenTree/settings/plugin.html" %} + +{% plugin_list as pl_list %} +{% for plugin_key, plugin in pl_list.items %} + {% if plugin.registered_mixins %} + {% include "InvenTree/settings/plugin_settings.html" %} + {% endif %} +{% endfor %} {% endif %} @@ -313,6 +322,10 @@ $("#import-part").click(function() { launchModalForm("{% url 'api-part-import' %}?reset", {}); }); +$("#install-plugin").click(function() { + installPlugin(); +}); + enableSidebar('settings'); {% endblock %} diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index 93b4ee1fb2..13c370ac16 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -1,6 +1,7 @@ {% load i18n %} {% load static %} {% load inventree_extras %} +{% load plugin_extras %} {% trans "User Settings" as text %} {% include "sidebar_header.html" with text=text icon='fa-user' %} @@ -46,4 +47,15 @@ {% trans "Sales Orders" as text %} {% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %} +{% include "sidebar_header.html" with text="Plugin Settings" %} + +{% include "sidebar_item.html" with label='plugin' text="Plugin" icon="fa-plug" %} + +{% plugin_list as pl_list %} +{% for plugin_key, plugin in pl_list.items %} + {% if plugin.registered_mixins %} + {% include "sidebar_item.html" with label='plugin-'|add:plugin_key text=plugin.human_name %} + {% endif %} +{% endfor %} + {% endif %} \ No newline at end of file diff --git a/InvenTree/templates/admin_button.html b/InvenTree/templates/admin_button.html index 7186246e4a..ebe767f9ac 100644 --- a/InvenTree/templates/admin_button.html +++ b/InvenTree/templates/admin_button.html @@ -1,4 +1,4 @@ {% load i18n %} - \ No newline at end of file diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 2bdd67679b..dd3a36282a 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -178,6 +178,7 @@ + diff --git a/InvenTree/templates/js/translated/plugin.js b/InvenTree/templates/js/translated/plugin.js new file mode 100644 index 0000000000..c612dd1e8c --- /dev/null +++ b/InvenTree/templates/js/translated/plugin.js @@ -0,0 +1,26 @@ +{% load i18n %} +{% load inventree_extras %} + +/* globals + constructForm, +*/ + +/* exported + installPlugin, +*/ + +function installPlugin() { + constructForm(`/api/plugin/install/`, { + method: 'POST', + title: '{% trans "Install Plugin" %}', + fields: { + packagename: {}, + url: {}, + confirm: {}, + }, + onSuccess: function(data) { + msg = '{% trans "The Plugin was installed" %}'; + showMessage(msg, {style: 'success', details: data.result, timeout: 30000}); + } + }); +} diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index b7ba79e498..76c129abdc 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -308,9 +308,17 @@ function getAvailableTableFilters(tableKey) { // Filters for PurchaseOrderLineItem table if (tableKey == 'purchaseorderlineitem') { return { - completed: { + pending: { type: 'bool', - title: '{% trans "Completed" %}', + title: '{% trans "Pending" %}', + }, + received: { + type: 'bool', + title: '{% trans "Received" %}', + }, + order_status: { + title: '{% trans "Order status" %}', + options: purchaseOrderCodes, }, }; } diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html index 334c1ebcc7..d2b4938624 100644 --- a/InvenTree/templates/navbar.html +++ b/InvenTree/templates/navbar.html @@ -1,9 +1,11 @@ {% load static %} {% load inventree_extras %} +{% load plugin_extras %} {% load i18n %} {% settings_value 'BARCODE_ENABLE' as barcodes %} {% settings_value 'STICKY_HEADER' user=request.user as sticky %} +{% navigation_enabled as plugin_nav %} {% inventree_demo_mode as demo %}