mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge branch 'matmair/issue2279' of https://github.com/matmair/InvenTree into matmair/issue2279
This commit is contained in:
		
							
								
								
									
										19
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										19
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
							
								
								
									
										21
									
								
								.github/workflows/version.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								.github/workflows/version.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 }} | ||||
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -78,3 +78,9 @@ locale_stats.json | ||||
|  | ||||
| # node.js | ||||
| node_modules/ | ||||
|  | ||||
| # maintenance locker | ||||
| maintenance_mode_state.txt | ||||
|  | ||||
| # plugin dev directory | ||||
| plugins/ | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
| @@ -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? | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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'] | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -19,7 +19,8 @@ def add_default_reference(apps, schema_editor): | ||||
|         build.save() | ||||
|         count += 1 | ||||
|  | ||||
|     print(f"\nUpdated build reference for {count} existing BuildOrder objects") | ||||
|     if count > 0: | ||||
|         print(f"\nUpdated build reference for {count} existing BuildOrder objects") | ||||
|  | ||||
|  | ||||
| def reverse_default_reference(apps, schema_editor): | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -17,6 +17,7 @@ | ||||
|   fields: | ||||
|     name: Zerg Corp | ||||
|     description: We eat the competition | ||||
|     is_customer: False | ||||
|  | ||||
| - model: company.company | ||||
|   pk: 4 | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
							
								
								
									
										7
									
								
								InvenTree/plugin/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								InvenTree/plugin/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| from .registry import plugins as plugin_reg | ||||
| from .integration import IntegrationPluginBase | ||||
| from .action import ActionPlugin | ||||
|  | ||||
| __all__ = [ | ||||
|     'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin', | ||||
| ] | ||||
| @@ -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 | ||||
							
								
								
									
										46
									
								
								InvenTree/plugin/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								InvenTree/plugin/admin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
							
								
								
									
										89
									
								
								InvenTree/plugin/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								InvenTree/plugin/api.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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<pk>\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'), | ||||
| ] | ||||
							
								
								
									
										21
									
								
								InvenTree/plugin/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								InvenTree/plugin/apps.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
							
								
								
									
										0
									
								
								InvenTree/plugin/builtin/action/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/builtin/action/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										25
									
								
								InvenTree/plugin/builtin/action/simpleactionplugin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								InvenTree/plugin/builtin/action/simpleactionplugin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										40
									
								
								InvenTree/plugin/builtin/action/test_samples_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								InvenTree/plugin/builtin/action/test_samples_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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", | ||||
|                 }, | ||||
|             } | ||||
|         ) | ||||
							
								
								
									
										0
									
								
								InvenTree/plugin/builtin/integration/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/builtin/integration/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										169
									
								
								InvenTree/plugin/builtin/integration/mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								InvenTree/plugin/builtin/integration/mixins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										102
									
								
								InvenTree/plugin/helpers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								InvenTree/plugin/helpers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										204
									
								
								InvenTree/plugin/integration.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								InvenTree/plugin/integration.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										19
									
								
								InvenTree/plugin/loader.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								InvenTree/plugin/loader.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
							
								
								
									
										23
									
								
								InvenTree/plugin/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								InvenTree/plugin/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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')), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @@ -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'}, | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										0
									
								
								InvenTree/plugin/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										6
									
								
								InvenTree/plugin/mixins/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								InvenTree/plugin/mixins/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| """utility class to enable simpler imports""" | ||||
| from ..builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin | ||||
|  | ||||
| __all__ = [ | ||||
|     'AppMixin', 'GlobalSettingsMixin', 'UrlsMixin', 'NavigationMixin', | ||||
| ] | ||||
							
								
								
									
										93
									
								
								InvenTree/plugin/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								InvenTree/plugin/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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): | ||||
							
								
								
									
										114
									
								
								InvenTree/plugin/plugins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								InvenTree/plugin/plugins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
							
								
								
									
										452
									
								
								InvenTree/plugin/registry.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										452
									
								
								InvenTree/plugin/registry.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
							
								
								
									
										0
									
								
								InvenTree/plugin/samples/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/samples/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								InvenTree/plugin/samples/integration/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/samples/integration/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										19
									
								
								InvenTree/plugin/samples/integration/another_sample.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								InvenTree/plugin/samples/integration/another_sample.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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" | ||||
							
								
								
									
										11
									
								
								InvenTree/plugin/samples/integration/broken_file.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								InvenTree/plugin/samples/integration/broken_file.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										16
									
								
								InvenTree/plugin/samples/integration/broken_sample.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								InvenTree/plugin/samples/integration/broken_sample.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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') | ||||
							
								
								
									
										48
									
								
								InvenTree/plugin/samples/integration/sample.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								InvenTree/plugin/samples/integration/sample.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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'}, | ||||
|     ] | ||||
| @@ -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') | ||||
							
								
								
									
										119
									
								
								InvenTree/plugin/serializers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								InvenTree/plugin/serializers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										60
									
								
								InvenTree/plugin/templatetags/plugin_extras.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								InvenTree/plugin/templatetags/plugin_extras.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										61
									
								
								InvenTree/plugin/test_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								InvenTree/plugin/test_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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', | ||||
|         }) | ||||
							
								
								
									
										117
									
								
								InvenTree/plugin/test_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								InvenTree/plugin/test_api.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
							
								
								
									
										216
									
								
								InvenTree/plugin/test_integration.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										216
									
								
								InvenTree/plugin/test_integration.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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') | ||||
							
								
								
									
										92
									
								
								InvenTree/plugin/test_plugin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								InvenTree/plugin/test_plugin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
							
								
								
									
										18
									
								
								InvenTree/plugin/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								InvenTree/plugin/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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'))) | ||||
| @@ -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 | ||||
| @@ -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): | ||||
|     """ | ||||
| @@ -174,6 +163,23 @@ class StockTransfer(StockAdjustView): | ||||
|     serializer_class = StockSerializers.StockTransferSerializer | ||||
|  | ||||
|  | ||||
| class StockAssign(generics.CreateAPIView): | ||||
|     """ | ||||
|     API endpoint for assigning stock to a particular customer | ||||
|     """ | ||||
|  | ||||
|     queryset = StockItem.objects.all() | ||||
|     serializer_class = StockSerializers.StockAssignmentSerializer | ||||
|  | ||||
|     def get_serializer_context(self): | ||||
|  | ||||
|         ctx = super().get_serializer_context() | ||||
|  | ||||
|         ctx['request'] = self.request | ||||
|  | ||||
|         return ctx | ||||
|  | ||||
|  | ||||
| class StockLocationList(generics.ListCreateAPIView): | ||||
|     """ | ||||
|     API endpoint for list view of StockLocation objects: | ||||
| @@ -623,9 +629,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): | ||||
| @@ -1188,6 +1191,7 @@ stock_api_urls = [ | ||||
|     url(r'^add/', StockAdd.as_view(), name='api-stock-add'), | ||||
|     url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'), | ||||
|     url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'), | ||||
|     url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'), | ||||
|  | ||||
|     # StockItemAttachment API endpoints | ||||
|     url(r'^attachment/', include([ | ||||
|   | ||||
| @@ -21,20 +21,6 @@ from part.models import Part | ||||
| from .models import StockLocation, StockItem, StockItemTracking | ||||
|  | ||||
|  | ||||
| class AssignStockItemToCustomerForm(HelperForm): | ||||
|     """ | ||||
|     Form for manually assigning a StockItem to a Customer | ||||
|  | ||||
|     TODO: This could be a simple API driven form! | ||||
|     """ | ||||
|  | ||||
|     class Meta: | ||||
|         model = StockItem | ||||
|         fields = [ | ||||
|             'customer', | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class ReturnStockItemForm(HelperForm): | ||||
|     """ | ||||
|     Form for manually returning a StockItem into stock | ||||
|   | ||||
							
								
								
									
										47
									
								
								InvenTree/stock/migrations/0071_auto_20211205_1733.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								InvenTree/stock/migrations/0071_auto_20211205_1733.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
|         ) | ||||
|     ] | ||||
| @@ -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', | ||||
|         ), | ||||
|     ] | ||||
| @@ -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: | ||||
|   | ||||
| @@ -28,6 +28,8 @@ from .models import StockItemTestResult | ||||
|  | ||||
| import common.models | ||||
| from common.settings import currency_code_default, currency_code_mappings | ||||
|  | ||||
| import company.models | ||||
| from company.serializers import SupplierPartSerializer | ||||
|  | ||||
| import InvenTree.helpers | ||||
| @@ -537,6 +539,127 @@ class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer): | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class StockAssignmentItemSerializer(serializers.Serializer): | ||||
|     """ | ||||
|     Serializer for a single StockItem with in StockAssignment request. | ||||
|  | ||||
|     Here, the particular StockItem is being assigned (manually) to a customer | ||||
|  | ||||
|     Fields: | ||||
|         - item: StockItem object | ||||
|     """ | ||||
|  | ||||
|     class Meta: | ||||
|         fields = [ | ||||
|             'item', | ||||
|         ] | ||||
|  | ||||
|     item = serializers.PrimaryKeyRelatedField( | ||||
|         queryset=StockItem.objects.all(), | ||||
|         many=False, | ||||
|         allow_null=False, | ||||
|         required=True, | ||||
|         label=_('Stock Item'), | ||||
|     ) | ||||
|  | ||||
|     def validate_item(self, item): | ||||
|  | ||||
|         # The item must currently be "in stock" | ||||
|         if not item.in_stock: | ||||
|             raise ValidationError(_("Item must be in stock")) | ||||
|  | ||||
|         # The base part must be "salable" | ||||
|         if not item.part.salable: | ||||
|             raise ValidationError(_("Part must be salable")) | ||||
|  | ||||
|         # The item must not be allocated to a sales order | ||||
|         if item.sales_order_allocations.count() > 0: | ||||
|             raise ValidationError(_("Item is allocated to a sales order")) | ||||
|  | ||||
|         # The item must not be allocated to a build order | ||||
|         if item.allocations.count() > 0: | ||||
|             raise ValidationError(_("Item is allocated to a build order")) | ||||
|  | ||||
|         return item | ||||
|  | ||||
|  | ||||
| class StockAssignmentSerializer(serializers.Serializer): | ||||
|     """ | ||||
|     Serializer for assigning one (or more) stock items to a customer. | ||||
|  | ||||
|     This is a manual assignment process, separate for (for example) a Sales Order | ||||
|     """ | ||||
|  | ||||
|     class Meta: | ||||
|         fields = [ | ||||
|             'items', | ||||
|             'customer', | ||||
|             'notes', | ||||
|         ] | ||||
|  | ||||
|     items = StockAssignmentItemSerializer( | ||||
|         many=True, | ||||
|         required=True, | ||||
|     ) | ||||
|  | ||||
|     customer = serializers.PrimaryKeyRelatedField( | ||||
|         queryset=company.models.Company.objects.all(), | ||||
|         many=False, | ||||
|         allow_null=False, | ||||
|         required=True, | ||||
|         label=_('Customer'), | ||||
|         help_text=_('Customer to assign stock items'), | ||||
|     ) | ||||
|  | ||||
|     def validate_customer(self, customer): | ||||
|  | ||||
|         if customer and not customer.is_customer: | ||||
|             raise ValidationError(_('Selected company is not a customer')) | ||||
|  | ||||
|         return customer | ||||
|  | ||||
|     notes = serializers.CharField( | ||||
|         required=False, | ||||
|         allow_blank=True, | ||||
|         label=_('Notes'), | ||||
|         help_text=_('Stock assignment notes'), | ||||
|     ) | ||||
|  | ||||
|     def validate(self, data): | ||||
|  | ||||
|         data = super().validate(data) | ||||
|  | ||||
|         items = data.get('items', []) | ||||
|  | ||||
|         if len(items) == 0: | ||||
|             raise ValidationError(_("A list of stock items must be provided")) | ||||
|  | ||||
|         return data | ||||
|  | ||||
|     def save(self): | ||||
|  | ||||
|         request = self.context['request'] | ||||
|  | ||||
|         user = getattr(request, 'user', None) | ||||
|  | ||||
|         data = self.validated_data | ||||
|  | ||||
|         items = data['items'] | ||||
|         customer = data['customer'] | ||||
|         notes = data.get('notes', '') | ||||
|  | ||||
|         with transaction.atomic(): | ||||
|             for item in items: | ||||
|  | ||||
|                 stock_item = item['item'] | ||||
|  | ||||
|                 stock_item.allocateToCustomer( | ||||
|                     customer, | ||||
|                     user=user, | ||||
|                     notes=notes, | ||||
|                 ) | ||||
|  | ||||
|  | ||||
| class StockAdjustmentItemSerializer(serializers.Serializer): | ||||
|     """ | ||||
|     Serializer for a single StockItem within a stock adjument request. | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -568,11 +568,19 @@ $("#stock-convert").click(function() { | ||||
|  | ||||
| {% if item.in_stock %} | ||||
| $("#stock-assign-to-customer").click(function() { | ||||
|     launchModalForm("{% url 'stock-item-assign' item.id %}", | ||||
|         { | ||||
|             reload: true, | ||||
|  | ||||
|     inventreeGet('{% url "api-stock-detail" item.pk %}', {}, { | ||||
|         success: function(response) { | ||||
|             assignStockToCustomer( | ||||
|                 [response], | ||||
|                 { | ||||
|                     success: function() { | ||||
|                         location.reload(); | ||||
|                     }, | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|     ); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| $("#stock-move").click(function() { | ||||
|   | ||||
| @@ -16,9 +16,9 @@ from InvenTree.status_codes import StockStatus | ||||
| from InvenTree.api_tester import InvenTreeAPITestCase | ||||
|  | ||||
| from common.models import InvenTreeSetting | ||||
|  | ||||
| from .models import StockItem, StockLocation | ||||
| from .tasks import delete_old_stock_items | ||||
| import company.models | ||||
| import part.models | ||||
| from stock.models import StockItem, StockLocation | ||||
|  | ||||
|  | ||||
| class StockAPITestCase(InvenTreeAPITestCase): | ||||
| @@ -593,11 +593,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 +611,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 +619,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): | ||||
| @@ -751,3 +733,112 @@ class StockTestResultTest(StockAPITestCase): | ||||
|  | ||||
|             # Check that an attachment has been uploaded | ||||
|             self.assertIsNotNone(response.data['attachment']) | ||||
|  | ||||
|  | ||||
| class StockAssignTest(StockAPITestCase): | ||||
|     """ | ||||
|     Unit tests for the stock assignment API endpoint, | ||||
|     where stock items are manually assigned to a customer | ||||
|     """ | ||||
|  | ||||
|     URL = reverse('api-stock-assign') | ||||
|  | ||||
|     def test_invalid(self): | ||||
|  | ||||
|         # Test with empty data | ||||
|         response = self.post( | ||||
|             self.URL, | ||||
|             data={}, | ||||
|             expected_code=400, | ||||
|         ) | ||||
|  | ||||
|         self.assertIn('This field is required', str(response.data['items'])) | ||||
|         self.assertIn('This field is required', str(response.data['customer'])) | ||||
|  | ||||
|         # Test with an invalid customer | ||||
|         response = self.post( | ||||
|             self.URL, | ||||
|             data={ | ||||
|                 'customer': 999, | ||||
|             }, | ||||
|             expected_code=400, | ||||
|         ) | ||||
|  | ||||
|         self.assertIn('object does not exist', str(response.data['customer'])) | ||||
|  | ||||
|         # Test with a company which is *not* a customer | ||||
|         response = self.post( | ||||
|             self.URL, | ||||
|             data={ | ||||
|                 'customer': 3, | ||||
|             }, | ||||
|             expected_code=400, | ||||
|         ) | ||||
|  | ||||
|         self.assertIn('company is not a customer', str(response.data['customer'])) | ||||
|  | ||||
|         # Test with an empty items list | ||||
|         response = self.post( | ||||
|             self.URL, | ||||
|             data={ | ||||
|                 'items': [], | ||||
|                 'customer': 4, | ||||
|             }, | ||||
|             expected_code=400, | ||||
|         ) | ||||
|  | ||||
|         self.assertIn('A list of stock items must be provided', str(response.data)) | ||||
|  | ||||
|         stock_item = StockItem.objects.create( | ||||
|             part=part.models.Part.objects.get(pk=1), | ||||
|             status=StockStatus.DESTROYED, | ||||
|             quantity=5, | ||||
|         ) | ||||
|  | ||||
|         response = self.post( | ||||
|             self.URL, | ||||
|             data={ | ||||
|                 'items': [ | ||||
|                     { | ||||
|                         'item': stock_item.pk, | ||||
|                     }, | ||||
|                 ], | ||||
|                 'customer': 4, | ||||
|             }, | ||||
|             expected_code=400, | ||||
|         ) | ||||
|  | ||||
|         self.assertIn('Item must be in stock', str(response.data['items'][0])) | ||||
|  | ||||
|     def test_valid(self): | ||||
|  | ||||
|         stock_items = [] | ||||
|  | ||||
|         for i in range(5): | ||||
|  | ||||
|             stock_item = StockItem.objects.create( | ||||
|                 part=part.models.Part.objects.get(pk=25), | ||||
|                 quantity=i + 5, | ||||
|             ) | ||||
|  | ||||
|             stock_items.append({ | ||||
|                 'item': stock_item.pk | ||||
|             }) | ||||
|  | ||||
|         customer = company.models.Company.objects.get(pk=4) | ||||
|  | ||||
|         self.assertEqual(customer.assigned_stock.count(), 0) | ||||
|  | ||||
|         response = self.post( | ||||
|             self.URL, | ||||
|             data={ | ||||
|                 'items': stock_items, | ||||
|                 'customer': 4, | ||||
|             }, | ||||
|             expected_code=201, | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(response.data['customer'], 4) | ||||
|  | ||||
|         # 5 stock items should now have been assigned to this customer | ||||
|         self.assertEqual(customer.assigned_stock.count(), 5) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -23,7 +23,6 @@ stock_item_detail_urls = [ | ||||
|     url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), | ||||
|     url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), | ||||
|     url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), | ||||
|     url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'), | ||||
|     url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'), | ||||
|     url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'), | ||||
|  | ||||
|   | ||||
| @@ -294,39 +294,6 @@ class StockLocationQRCode(QRCodeView): | ||||
|             return None | ||||
|  | ||||
|  | ||||
| class StockItemAssignToCustomer(AjaxUpdateView): | ||||
|     """ | ||||
|     View for manually assigning a StockItem to a Customer | ||||
|     """ | ||||
|  | ||||
|     model = StockItem | ||||
|     ajax_form_title = _("Assign to Customer") | ||||
|     context_object_name = "item" | ||||
|     form_class = StockForms.AssignStockItemToCustomerForm | ||||
|  | ||||
|     def validate(self, item, form, **kwargs): | ||||
|  | ||||
|         customer = form.cleaned_data.get('customer', None) | ||||
|  | ||||
|         if not customer: | ||||
|             form.add_error('customer', _('Customer must be specified')) | ||||
|  | ||||
|     def save(self, item, form, **kwargs): | ||||
|         """ | ||||
|         Assign the stock item to the customer. | ||||
|         """ | ||||
|  | ||||
|         customer = form.cleaned_data.get('customer', None) | ||||
|  | ||||
|         if customer: | ||||
|             item = item.allocateToCustomer( | ||||
|                 customer, | ||||
|                 user=self.request.user | ||||
|             ) | ||||
|  | ||||
|             item.clearAllocations() | ||||
|  | ||||
|  | ||||
| class StockItemReturnToStock(AjaxUpdateView): | ||||
|     """ | ||||
|     View for returning a stock item (which is assigned to a customer) to stock. | ||||
|   | ||||
							
								
								
									
										73
									
								
								InvenTree/templates/503.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								InvenTree/templates/503.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| {% extends "skeleton.html" %} | ||||
| {% load static %} | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block head %} | ||||
| <meta http-equiv="refresh" content="30"> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block page_title %} | ||||
| {% trans 'Site is in Maintenance' %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block body_class %}login-screen{% endblock %} | ||||
|  | ||||
| {% block body %} | ||||
|     <!--  | ||||
|         Background Image Attribution: https://unsplash.com/photos/Ixvv3YZkd7w | ||||
|     --> | ||||
|     <div class='container-fluid'> | ||||
|         <div class='notification-area' id='alerts'> | ||||
|             <!-- Div for displayed alerts --> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class='main body-wrapper login-screen d-flex'> | ||||
|  | ||||
|  | ||||
|         <div class='login-container'> | ||||
|         <div class="row"> | ||||
|             <div class='container-fluid'> | ||||
|  | ||||
|                 <div class='clearfix content-heading login-header d-flex flex-wrap'> | ||||
|                     <img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/> | ||||
|                     {% include "spacer.html" %} | ||||
|                     <span class='float-right'><h3>{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock %}</h3></span> | ||||
|                 </div> | ||||
|             </div> | ||||
|                 <div class='container-fluid'> | ||||
|                     <hr> | ||||
|                     {% block content %} | ||||
|                     {% trans 'The site is currently in maintenance and should be up again soon!' %} | ||||
|                     {% endblock %} | ||||
|                 </div> | ||||
|         </div> | ||||
|         </div> | ||||
|  | ||||
|         {% block extra_body %} | ||||
|         {% endblock %} | ||||
|     </div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block js_base %} | ||||
| <script type='text/javascript'> | ||||
| $(document).ready(function () { | ||||
|     // notifications | ||||
|     {% if messages %} | ||||
|     {% for message in messages %} | ||||
|     showAlertOrCache( | ||||
|         '{{ message }}', | ||||
|         true, | ||||
|         { | ||||
|             style: 'info', | ||||
|         } | ||||
|     ); | ||||
|     {% endfor %} | ||||
|     {% endif %} | ||||
|  | ||||
|     inventreeDocReady(); | ||||
| }); | ||||
| </script> | ||||
| {% endblock %} | ||||
| </body> | ||||
| </html> | ||||
							
								
								
									
										13
									
								
								InvenTree/templates/InvenTree/settings/mixins/settings.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								InvenTree/templates/InvenTree/settings/mixins/settings.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| {% load i18n %} | ||||
| {% load plugin_extras %} | ||||
|  | ||||
| <h4>{% trans "Settings" %}</h4> | ||||
| {% plugin_globalsettings plugin_key as plugin_settings %} | ||||
|  | ||||
| <table class='table table-striped table-condensed'> | ||||
|     <tbody> | ||||
|     {% for setting in plugin_settings %} | ||||
|         {% include "InvenTree/settings/setting.html" with key=setting%} | ||||
|     {% endfor %} | ||||
|     </tbody> | ||||
| </table> | ||||
							
								
								
									
										25
									
								
								InvenTree/templates/InvenTree/settings/mixins/urls.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								InvenTree/templates/InvenTree/settings/mixins/urls.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| {% load i18n %} | ||||
| {% load inventree_extras %} | ||||
|  | ||||
| <h4>{% trans "URLs" %}</h4> | ||||
| {% define plugin.base_url as base %} | ||||
| <p>{% blocktrans %}The Base-URL for this plugin is <a href="/{{ base }}" target="_blank"><strong>{{ base }}</strong></a>.{% endblocktrans %}</p> | ||||
|  | ||||
| <table class='table table-striped table-condensed'> | ||||
|     <thead> | ||||
|         <tr> | ||||
|             <th>{% trans "Name" %}</th> | ||||
|             <th>{% trans "URL" %}</th> | ||||
|             <th></th> | ||||
|         </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|         {% for key, entry in plugin.urlpatterns.reverse_dict.items %}{% if key %} | ||||
|         <tr> | ||||
|             <td>{{key}}</td> | ||||
|             <td>{{entry.1}}</td> | ||||
|             <td><a class="btn btn-primary btn-small" href="/{{ base }}{{entry.1}}" target="_blank">{% trans 'open in new tab' %}</a></td> | ||||
|         </tr> | ||||
|         {% endif %}{% endfor %} | ||||
|     </tbody> | ||||
| </table> | ||||
							
								
								
									
										146
									
								
								InvenTree/templates/InvenTree/settings/plugin.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								InvenTree/templates/InvenTree/settings/plugin.html
									
									
									
									
									
										Normal file
									
								
							| @@ -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 %} | ||||
|  | ||||
| <div class='alert alert-block alert-danger'> | ||||
|     {% trans "Changing the settings below require you to immediatly restart InvenTree. Do not change this while under active usage." %} | ||||
| </div> | ||||
|  | ||||
| <div class='table-responsive'> | ||||
| <table class='table table-striped table-condensed'> | ||||
|     <tbody> | ||||
|         {% 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"%} | ||||
|     </tbody> | ||||
| </table> | ||||
| </div> | ||||
|  | ||||
| <div class='panel-heading'> | ||||
|     <div class='d-flex flex-wrap'> | ||||
|         <h4>{% trans "Plugin list" %}</h4> | ||||
|         {% include "spacer.html" %} | ||||
|         <div class='btn-group' role='group'> | ||||
|             {% url 'admin:plugin_pluginconfig_changelist' as url %} | ||||
|             {% include "admin_button.html" with url=url %} | ||||
|             <button class="btn btn-success" id="install-plugin" title="{% trans 'Install Plugin' %}"><span class='fas fa-plus-circle'></span> {% trans "Install Plugin" %}</button>         | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <div class='table-responsive'> | ||||
| <table class='table table-striped table-condensed'> | ||||
|     <thead> | ||||
|         <tr> | ||||
|             <th>{% trans "Admin" %}</th> | ||||
|             <th>{% trans "Name" %}</th> | ||||
|             <th>{% trans "Author" %}</th> | ||||
|             <th>{% trans "Date" %}</th> | ||||
|             <th>{% trans "Version" %}</th> | ||||
|         </tr> | ||||
|     </thead> | ||||
|      | ||||
|     <tbody> | ||||
|         {% 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 %} | ||||
|  | ||||
|         <tr> | ||||
|             <td> | ||||
|                 {% 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 %} | ||||
|             </td> | ||||
|             <td>{{ plugin.human_name }}<span class="text-muted"> - {{plugin_key}}</span> | ||||
|                 {% define plugin.registered_mixins as mixin_list %} | ||||
|  | ||||
|                 {% if mixin_list %} | ||||
|                 {% for mixin in mixin_list %} | ||||
|                 <a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar"> | ||||
|                     <span class='badge bg-dark badge-right'>{% blocktrans with name=mixin.human_name %}has {{name}}{% endblocktrans %}</span> | ||||
|                 </a> | ||||
|                 {% endfor %} | ||||
|                 {% endif %} | ||||
|  | ||||
|                 {% if plugin.website %} | ||||
|                 <a href="{{ plugin.website }}"><i class="fas fa-globe"></i></a> | ||||
|                 {% endif %} | ||||
|             </td> | ||||
|             <td>{{ plugin.author }}</td> | ||||
|             <td>{{ plugin.pub_date }}</td> | ||||
|             <td>{% if plugin.version %}{{ plugin.version }}{% endif %}</td> | ||||
|         </tr> | ||||
|         {% endfor %} | ||||
|  | ||||
|         {% inactive_plugin_list as in_pl_list %} | ||||
|         {% if in_pl_list %} | ||||
|         <tr><td colspan="5"></td></tr> | ||||
|         <tr><td colspan="5"><h6>{% trans 'Inactive plugins' %}</h6></td></tr> | ||||
|         {% for plugin_key, plugin in in_pl_list.items %} | ||||
|         <tr> | ||||
|             <td> | ||||
|                 {% 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 %} | ||||
|             </td> | ||||
|             <td>{{plugin.name}}<span class="text-muted"> - {{plugin.key}}</span></td> | ||||
|             <td colspan="3"></td> | ||||
|         </tr> | ||||
|         {% endfor %} | ||||
|         {% endif %} | ||||
|     </tbody> | ||||
| </table> | ||||
| </div> | ||||
|  | ||||
|  | ||||
| {% plugin_errors as pl_errors %} | ||||
| {% if pl_errors %} | ||||
| <div class='panel-heading'> | ||||
|     <div class='d-flex flex-wrap'> | ||||
|         <h4>{% trans "Plugin Error Stack" %}</h4> | ||||
|         {% include "spacer.html" %} | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <div class='table-responsive'> | ||||
|     <table class='table table-striped table-condensed'> | ||||
|         <thead> | ||||
|             <tr> | ||||
|                 <th>{% trans "Stage" %}</th> | ||||
|                 <th>{% trans "Name" %}</th> | ||||
|                 <th>{% trans "Message" %}</th> | ||||
|             </tr> | ||||
|         </thead> | ||||
|          | ||||
|         <tbody> | ||||
|         {% for stage, errors in pl_errors.items %} | ||||
|             {% for error_detail in errors %} | ||||
|             {% for name, message in error_detail.items %} | ||||
|             <tr> | ||||
|                 <td>{{ stage }}</td> | ||||
|                 <td>{{ name }}</td> | ||||
|                 <td>{{ message }}</td> | ||||
|             </tr> | ||||
|             {% endfor %} | ||||
|             {% endfor %} | ||||
|         {% endfor %} | ||||
|         </tbody> | ||||
|     </table> | ||||
|     </div> | ||||
| {% endif %} | ||||
|  | ||||
| {% endblock %} | ||||
							
								
								
									
										137
									
								
								InvenTree/templates/InvenTree/settings/plugin_settings.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								InvenTree/templates/InvenTree/settings/plugin_settings.html
									
									
									
									
									
										Normal file
									
								
							| @@ -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 %} | ||||
|  | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         <h4>{% trans "Plugin information" %}</h4> | ||||
|         <div class='table-responsive'> | ||||
|         <table class='table table-striped table-condensed'> | ||||
|             <col width='25'> | ||||
|             <tr> | ||||
|                 <td></td> | ||||
|                 <td>{% trans "Name" %}</td> | ||||
|                 <td>{{ plugin.human_name }}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-user'></span></span></td> | ||||
|                 <td>{% trans "Author" %}</td> | ||||
|                 <td>{{ plugin.author }}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td></td> | ||||
|                 <td>{% trans "Description" %}</td> | ||||
|                 <td>{{ plugin.description }}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-calendar-alt'></span></td> | ||||
|                 <td>{% trans "Date" %}</td> | ||||
|                 <td>{{ plugin.pub_date }}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-hashtag'></span></td> | ||||
|                 <td>{% trans "Version" %}</td> | ||||
|                 <td> | ||||
|                     {% if plugin.version %} | ||||
|                         {{ plugin.version }}{% include "clip.html" %} | ||||
|                     {% else %} | ||||
|                         {% trans 'no version information supplied' %} | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
|             </tr> | ||||
|             {% if plugin.website %} | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-globe'></span></td> | ||||
|                 <td>{% trans "Website" %}</td> | ||||
|                 <td>{{ plugin.website }}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             {% endif %} | ||||
|             {% if plugin.license %} | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-balance-scale'></span></td> | ||||
|                 <td>{% trans "License" %}</td> | ||||
|                 <td>{{ plugin.license }}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             {% endif %} | ||||
|         </table> | ||||
|         </div> | ||||
|  | ||||
|         {% if plugin.is_package == False %} | ||||
|         <p>{% 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.' %}</p> | ||||
|         {% endif %} | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         <h4>{% trans "Package information" %}</h4> | ||||
|         <div class='table-responsive'> | ||||
|         <table class='table table-striped table-condensed'> | ||||
|             <col width='25'> | ||||
|             <tr> | ||||
|                 <td></td> | ||||
|                 <td>{% trans "Installation method" %}</td> | ||||
|                 <td> | ||||
|                     {% if plugin.is_package %} | ||||
|                     {% trans "This plugin was installed as a package" %} | ||||
|                     {% else %} | ||||
|                     {% trans "This plugin was found in a local InvenTree path" %} | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td></td> | ||||
|                 <td>{% trans "Installation path" %}</td> | ||||
|                 <td>{{ plugin.package_path }}</td> | ||||
|             </tr> | ||||
|             {% if plugin.is_package == False %} | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-user'></span></td> | ||||
|                 <td>{% trans "Commit Author" %}</td><td>{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-calendar-alt'></span></td> | ||||
|                 <td>{% trans "Commit Date" %}</td><td>{{ plugin.package.date }}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-code-branch'></span></td> | ||||
|                 <td>{% trans "Commit Hash" %}</td><td>{{ plugin.package.hash }}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td><span class='fas fa-envelope'></span></td> | ||||
|                 <td>{% trans "Commit Message" %}</td><td>{{ plugin.package.message }}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|             {% endif %} | ||||
|             <tr> | ||||
|                 <td><span class='text-{{plugin.sign_color}} fas fa-check'></span></td> | ||||
|                 <td>{% trans "Sign Status" %}</td> | ||||
|                 <td class="bg-{{plugin.sign_color}}">{% if plugin.package.verified %}{{ plugin.package.verified }}: {% endif%}{{ plugin.sign_state.msg }}</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|                 <td><span class='text-{{plugin.sign_color}} fas fa-key'></span></td> | ||||
|                 <td>{% trans "Sign Key" %}</td> | ||||
|                 <td class="bg-{{plugin.sign_color}}">{{ plugin.package.key }}{% include "clip.html" %}</td> | ||||
|             </tr> | ||||
|         </table> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| {% 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 %} | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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 %} | ||||
| @@ -1,4 +1,4 @@ | ||||
| {% load i18n %} | ||||
| <button id='admin-button' title='{% trans "View in administration panel" %}' type='button' class='btn btn-primary' url='{{ url }}'> | ||||
| <button id='admin-button' title='{% trans "View in administration panel" %}' type='button' class='btn btn-primary admin-button' url='{{ url }}'> | ||||
|     <span class='fas fa-user-shield'></span> | ||||
| </button> | ||||
| @@ -178,6 +178,7 @@ | ||||
| <script type='text/javascript' src="{% i18n_static 'part.js' %}"></script> | ||||
| <script type='text/javascript' src="{% i18n_static 'report.js' %}"></script> | ||||
| <script type='text/javascript' src="{% i18n_static 'stock.js' %}"></script> | ||||
| <script type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script> | ||||
| <script type='text/javascript' src="{% i18n_static 'tables.js' %}"></script> | ||||
| <script type='text/javascript' src="{% i18n_static 'table_filters.js' %}"></script> | ||||
|  | ||||
|   | ||||
							
								
								
									
										26
									
								
								InvenTree/templates/js/translated/plugin.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								InvenTree/templates/js/translated/plugin.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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}); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
| @@ -38,6 +38,7 @@ | ||||
| */ | ||||
|  | ||||
| /* exported | ||||
|     assignStockToCustomer, | ||||
|     createNewStockItem, | ||||
|     createStockLocation, | ||||
|     duplicateStockItem, | ||||
| @@ -533,13 +534,166 @@ function exportStock(params={}) { | ||||
|                 url += `&${key}=${params[key]}`; | ||||
|             } | ||||
|  | ||||
|             console.log(url); | ||||
|             location.href = url; | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * Assign multiple stock items to a customer | ||||
|  */ | ||||
| function assignStockToCustomer(items, options={}) { | ||||
|  | ||||
|     // Generate HTML content for the form | ||||
|     var html = ` | ||||
|     <table class='table table-striped table-condensed' id='stock-assign-table'> | ||||
|     <thead> | ||||
|         <tr> | ||||
|             <th>{% trans "Part" %}</th> | ||||
|             <th>{% trans "Stock Item" %}</th> | ||||
|             <th>{% trans "Location" %}</th> | ||||
|             <th></th> | ||||
|         </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|     `; | ||||
|  | ||||
|     for (var idx = 0; idx < items.length; idx++) { | ||||
|  | ||||
|         var item = items[idx]; | ||||
|          | ||||
|         var pk = item.pk; | ||||
|  | ||||
|         var part = item.part_detail; | ||||
|  | ||||
|         var thumbnail = thumbnailImage(part.thumbnail || part.image); | ||||
|  | ||||
|         var status = stockStatusDisplay(item.status, {classes: 'float-right'}); | ||||
|  | ||||
|         var quantity = ''; | ||||
|  | ||||
|         if (item.serial && item.quantity == 1) { | ||||
|             quantity = `{% trans "Serial" %}: ${item.serial}`; | ||||
|         } else { | ||||
|             quantity = `{% trans "Quantity" %}: ${item.quantity}`; | ||||
|         } | ||||
|  | ||||
|         quantity += status; | ||||
|  | ||||
|         var location = locationDetail(item, false); | ||||
|  | ||||
|         var buttons = `<div class='btn-group' role='group'>`; | ||||
|  | ||||
|         buttons += makeIconButton( | ||||
|             'fa-times icon-red', | ||||
|             'button-stock-item-remove', | ||||
|             pk, | ||||
|             '{% trans "Remove row" %}', | ||||
|         ); | ||||
|  | ||||
|         buttons += '</div>'; | ||||
|  | ||||
|         html += ` | ||||
|             <tr id='stock_item_${pk}' class='stock-item'row'> | ||||
|                 <td id='part_${pk}'>${thumbnail} ${part.full_name}</td> | ||||
|                 <td id='stock_${pk}'> | ||||
|                     <div id='div_id_items_item_${pk}'> | ||||
|                         ${quantity} | ||||
|                         <div id='errors-items_item_${pk}'></div> | ||||
|                     </div> | ||||
|                 </td> | ||||
|                 <td id='location_${pk}'>${location}</td> | ||||
|                 <td id='buttons_${pk}'>${buttons}</td> | ||||
|             </tr> | ||||
|         `; | ||||
|     } | ||||
|  | ||||
|     html += `</tbody></table>`; | ||||
|  | ||||
|     constructForm('{% url "api-stock-assign" %}', { | ||||
|         method: 'POST', | ||||
|         preFormContent: html, | ||||
|         fields: { | ||||
|             'customer': { | ||||
|                 value: options.customer, | ||||
|                 filters: { | ||||
|                     is_customer: true, | ||||
|                 }, | ||||
|             }, | ||||
|             'notes': {}, | ||||
|         }, | ||||
|         confirm: true, | ||||
|         confirmMessage: '{% trans "Confirm stock assignment" %}', | ||||
|         title: '{% trans "Assign Stock to Customer" %}', | ||||
|         afterRender: function(fields, opts) { | ||||
|             // Add button callbacks to remove rows | ||||
|             $(opts.modal).find('.button-stock-item-remove').click(function() { | ||||
|                 var pk = $(this).attr('pk'); | ||||
|  | ||||
|                 $(opts.modal).find(`#stock_item_${pk}`).remove(); | ||||
|             }); | ||||
|         }, | ||||
|         onSubmit: function(fields, opts) { | ||||
|  | ||||
|             // Extract data elements from the form | ||||
|             var data = { | ||||
|                 customer: getFormFieldValue('customer', {}, opts), | ||||
|                 notes: getFormFieldValue('notes', {}, opts), | ||||
|                 items: [], | ||||
|             }; | ||||
|  | ||||
|             var item_pk_values = []; | ||||
|  | ||||
|             items.forEach(function(item) { | ||||
|                 var pk = item.pk; | ||||
|  | ||||
|                 // Does the row exist in the form? | ||||
|                 var row = $(opts.modal).find(`#stock_item_${pk}`); | ||||
|  | ||||
|                 if (row.exists()) { | ||||
|                     item_pk_values.push(pk); | ||||
|  | ||||
|                     data.items.push({ | ||||
|                         item: pk, | ||||
|                     }); | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             opts.nested = { | ||||
|                 'items': item_pk_values, | ||||
|             }; | ||||
|  | ||||
|             inventreePut( | ||||
|                 '{% url "api-stock-assign" %}', | ||||
|                 data, | ||||
|                 { | ||||
|                     method: 'POST', | ||||
|                     success: function(response) { | ||||
|                         $(opts.modal).modal('hide'); | ||||
|  | ||||
|                         if (options.success) { | ||||
|                             options.success(response); | ||||
|                         } | ||||
|                     }, | ||||
|                     error: function(xhr) { | ||||
|                         switch (xhr.status) { | ||||
|                         case 400: | ||||
|                             handleFormErrors(xhr.responseJSON, fields, opts); | ||||
|                             break; | ||||
|                         default: | ||||
|                             $(opts.modal).modal('hide'); | ||||
|                             showApiError(xhr, opts.url); | ||||
|                             break; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * Perform stock adjustments | ||||
|  */ | ||||
| @@ -777,7 +931,7 @@ function adjustStock(action, items, options={}) { | ||||
|                 // Does the row exist in the form? | ||||
|                 var row = $(opts.modal).find(`#stock_item_${pk}`); | ||||
|  | ||||
|                 if (row) { | ||||
|                 if (row.exists()) { | ||||
|  | ||||
|                     item_pk_values.push(pk); | ||||
|                      | ||||
| @@ -1098,7 +1252,7 @@ function locationDetail(row, showLink=true) { | ||||
|         // StockItem has been assigned to a sales order | ||||
|         text = '{% trans "Assigned to Sales Order" %}'; | ||||
|         url = `/order/sales-order/${row.sales_order}/`; | ||||
|     } else if (row.location) { | ||||
|     } else if (row.location && row.location_detail) { | ||||
|         text = row.location_detail.pathstring; | ||||
|         url = `/stock/location/${row.location}/`; | ||||
|     } else { | ||||
| @@ -1721,6 +1875,17 @@ function loadStockTable(table, options) { | ||||
|         stockAdjustment('move'); | ||||
|     }); | ||||
|  | ||||
|     $('#multi-item-assign').click(function() { | ||||
|  | ||||
|         var items = $(table).bootstrapTable('getSelections'); | ||||
|  | ||||
|         assignStockToCustomer(items, { | ||||
|             success: function() { | ||||
|                 $(table).bootstrapTable('refresh'); | ||||
|             } | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     $('#multi-item-order').click(function() { | ||||
|         var selections = $(table).bootstrapTable('getSelections'); | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|             }, | ||||
|         }; | ||||
|     } | ||||
|   | ||||
| @@ -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 %} | ||||
|  | ||||
| <nav class="navbar {% if sticky %}fixed-top{% endif %} navbar-expand-lg navbar-light"> | ||||
| @@ -57,6 +59,29 @@ | ||||
|           </ul> | ||||
|         </li> | ||||
|         {% endif %} | ||||
|  | ||||
|         {% if plugin_nav %} | ||||
|         {% plugin_list as pl_list %} | ||||
|         {% for plugin_key, plugin in pl_list.items %} | ||||
|           {% mixin_enabled plugin 'navigation' as navigation %} | ||||
|           {% if navigation %} | ||||
|           <li class='nav-item dropdown'> | ||||
|             <a class='nav-link dropdown-toggle' data-bs-toggle="dropdown" aria-expanded="false" href='#'> | ||||
|               <span class='{{plugin.navigation_icon}} icon-header'></span>{{plugin.navigation_name}} | ||||
|             </a> | ||||
|             <ul class='dropdown-menu'> | ||||
|              {% for nav_item in plugin.navigation %} | ||||
|                 {% safe_url nav_item.link as nav_link %} | ||||
|                 {% if nav_link %} | ||||
|                 <li><a href="{{ nav_link }}" class="dropdown-item"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a> | ||||
|                 {% endif %} | ||||
|              {% endfor %} | ||||
|             </ul> | ||||
|           </li> | ||||
|           {% endif %} | ||||
|         {% endfor %} | ||||
|         {% endif %} | ||||
|  | ||||
|       </ul> | ||||
|     </div> | ||||
|     {% if demo %} | ||||
|   | ||||
							
								
								
									
										99
									
								
								InvenTree/templates/skeleton.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								InvenTree/templates/skeleton.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| {% load static %} | ||||
| {% load i18n %} | ||||
|  | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| <head> | ||||
|  | ||||
| <!-- Required meta tags --> | ||||
| <meta charset="utf-8"> | ||||
| <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||||
|  | ||||
| <!-- Favicon --> | ||||
| <link rel="apple-touch-icon" sizes="57x57" href="{% static 'img/favicon/apple-icon-57x57.png' %}"> | ||||
| <link rel="apple-touch-icon" sizes="60x60" href="{% static 'img/favicon/apple-icon-60x60.png' %}"> | ||||
| <link rel="apple-touch-icon" sizes="72x72" href="{% static 'img/favicon/apple-icon-72x72.png' %}"> | ||||
| <link rel="apple-touch-icon" sizes="76x76" href="{% static 'img/favicon/apple-icon-76x76.png' %}"> | ||||
| <link rel="apple-touch-icon" sizes="114x114" href="{% static 'img/favicon/apple-icon-114x114.png' %}"> | ||||
| <link rel="apple-touch-icon" sizes="120x120" href="{% static 'img/favicon/apple-icon-120x120.png' %}"> | ||||
| <link rel="apple-touch-icon" sizes="144x144" href="{% static 'img/favicon/apple-icon-144x144.png' %}"> | ||||
| <link rel="apple-touch-icon" sizes="152x152" href="{% static 'img/favicon/apple-icon-152x152.png' %}"> | ||||
| <link rel="apple-touch-icon" sizes="180x180" href="{% static 'img/favicon/apple-icon-180x180.png' %}"> | ||||
| <link rel="icon" type="image/png" sizes="192x192"  href="{% static 'img/favicon/android-icon-192x192.png' %}"> | ||||
| <link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}"> | ||||
| <link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}"> | ||||
| <link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}"> | ||||
| <link rel="manifest" href="{% static 'img/favicon/manifest.json' %}"> | ||||
| <meta name="msapplication-TileColor" content="#ffffff"> | ||||
| <meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}"> | ||||
| <meta name="theme-color" content="#ffffff"> | ||||
|  | ||||
|  | ||||
| <!-- CSS --> | ||||
| <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}"> | ||||
| <link rel="stylesheet" href="{% static 'fontawesome/css/brands.css' %}"> | ||||
| <link rel="stylesheet" href="{% static 'fontawesome/css/solid.css' %}"> | ||||
| <link rel="stylesheet" href="{% static 'select2/css/select2.css' %}"> | ||||
| <link rel="stylesheet" href="{% static 'select2/css/select2-bootstrap-5-theme.css' %}"> | ||||
| <link rel="stylesheet" href="{% static 'css/inventree.css' %}"> | ||||
|  | ||||
| {% block head_css %} | ||||
| {% endblock %} | ||||
|  | ||||
| <style> | ||||
|     {% block css %} | ||||
|     {% endblock %} | ||||
| </style> | ||||
|  | ||||
| {% block head %} | ||||
| {% endblock %} | ||||
|  | ||||
| <title> | ||||
| {% block page_title %} | ||||
| {% endblock %} | ||||
| </title> | ||||
| </head> | ||||
|  | ||||
| <body class='{% block body_class %}{% endblock %}'> | ||||
| {% block body %} | ||||
| {% endblock %} | ||||
|  | ||||
| <!-- Scripts --> | ||||
| <script type="text/javascript" src="{% static 'script/jquery_3.3.1_jquery.min.js' %}"></script> | ||||
| <script type='text/javascript' src="{% static 'script/jquery-ui/jquery-ui.min.js' %}"></script> | ||||
| <script type="text/javascript" src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script> | ||||
| {% block body_scripts_general %} | ||||
| {% endblock %} | ||||
|  | ||||
|  | ||||
| <!-- 3rd party general js --> | ||||
| <script type="text/javascript" src="{% static 'fullcalendar/main.js' %}"></script> | ||||
| <script type="text/javascript" src="{% static 'fullcalendar/locales-all.js' %}"></script> | ||||
| <script type="text/javascript" src="{% static 'select2/js/select2.full.js' %}"></script> | ||||
| <script type='text/javascript' src="{% static 'script/moment.js' %}"></script> | ||||
| <script type='text/javascript' src="{% static 'script/chart.min.js' %}"></script> | ||||
| <script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script> | ||||
| <script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script> | ||||
|  | ||||
| <!-- general InvenTree --> | ||||
| <script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script> | ||||
| <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> | ||||
| {% block body_scripts_inventree %} | ||||
| {% endblock %} | ||||
|  | ||||
| <!-- fontawesome --> | ||||
| <script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script> | ||||
| <script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script> | ||||
| <script type='text/javascript' src="{% static 'fontawesome/js/fontawesome.js' %}"></script> | ||||
|  | ||||
| {% block js_load %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block js_base %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block js %} | ||||
| {% endblock %} | ||||
|  | ||||
| </body> | ||||
| </html> | ||||
| @@ -50,6 +50,7 @@ | ||||
|                     <li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li> | ||||
|                     <li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li> | ||||
|                     <li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li> | ||||
|                     <li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li> | ||||
|                     <li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li> | ||||
|                     {% endif %} | ||||
|                     {% if roles.stock.delete %} | ||||
|   | ||||
| @@ -73,6 +73,7 @@ class RuleSet(models.Model): | ||||
|             'socialaccount_socialaccount', | ||||
|             'socialaccount_socialapp', | ||||
|             'socialaccount_socialtoken', | ||||
|             'plugin_pluginconfig' | ||||
|         ], | ||||
|         'part_category': [ | ||||
|             'part_partcategory', | ||||
|   | ||||
| @@ -15,6 +15,7 @@ django-error-report==0.2.0      # Error report viewer for the admin interface | ||||
| django-filter==2.4.0            # Extended filtering options | ||||
| django-formtools==2.3           # Form wizard tools | ||||
| django-import-export==2.5.0     # Data import / export for admin interface | ||||
| django-maintenance-mode==0.16.1 # Shut down application while reloading etc. | ||||
| django-markdownify==0.8.0       # Markdown rendering | ||||
| django-markdownx==3.0.1         # Markdown form fields | ||||
| django-money==1.1               # Django app for currency management | ||||
| @@ -28,6 +29,7 @@ django-weasyprint==1.0.1        # django weasyprint integration | ||||
| djangorestframework==3.12.4     # DRF framework | ||||
| flake8==3.8.3                   # PEP checking | ||||
| gunicorn>=20.1.0                # Gunicorn web server | ||||
| importlib_metadata              # Backport for importlib.metadata | ||||
| inventree                       # Install the latest version of the InvenTree API python library | ||||
| markdown==3.3.4                 # Force particular version of markdown | ||||
| pep8-naming==0.11.1             # PEP naming convention extension | ||||
|   | ||||
		Reference in New Issue
	
	Block a user