mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +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: | 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: |   pep_style: | ||||||
|     name: PEP style (python) |     name: PEP style (python) | ||||||
|     needs: check_version |  | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     if: always() |  | ||||||
|  |  | ||||||
|     steps: |     steps: | ||||||
|       - name: Checkout code |       - 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.js | ||||||
| node_modules/ | 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 .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName | ||||||
| from .status import is_worker_running | 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 = logging.getLogger("inventree") | ||||||
|  |  | ||||||
|  |  | ||||||
| logger.info("Loading action plugins...") | logger.info("Loading action plugins...") | ||||||
| action_plugins = inventree_plugins.load_action_plugins() | action_plugins = load_action_plugins() | ||||||
|  |  | ||||||
|  |  | ||||||
| class InfoView(AjaxView): | class InfoView(AjaxView): | ||||||
|   | |||||||
| @@ -18,11 +18,32 @@ class InvenTreeConfig(AppConfig): | |||||||
|     def ready(self): |     def ready(self): | ||||||
|  |  | ||||||
|         if canAppAccessDatabase(): |         if canAppAccessDatabase(): | ||||||
|  |  | ||||||
|  |             self.remove_obsolete_tasks() | ||||||
|  |  | ||||||
|             self.start_background_tasks() |             self.start_background_tasks() | ||||||
|  |  | ||||||
|             if not isInTestMode(): |             if not isInTestMode(): | ||||||
|                 self.update_exchange_rates() |                 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): |     def start_background_tasks(self): | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
| @@ -57,25 +78,12 @@ class InvenTreeConfig(AppConfig): | |||||||
|             schedule_type=Schedule.DAILY, |             schedule_type=Schedule.DAILY, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Remove expired sessions |  | ||||||
|         InvenTree.tasks.schedule_task( |  | ||||||
|             'InvenTree.tasks.delete_expired_sessions', |  | ||||||
|             schedule_type=Schedule.DAILY, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         # Delete old error messages |         # Delete old error messages | ||||||
|         InvenTree.tasks.schedule_task( |         InvenTree.tasks.schedule_task( | ||||||
|             'InvenTree.tasks.delete_old_error_logs', |             'InvenTree.tasks.delete_old_error_logs', | ||||||
|             schedule_type=Schedule.DAILY, |             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 |         # Delete old notification records | ||||||
|         InvenTree.tasks.schedule_task( |         InvenTree.tasks.schedule_task( | ||||||
|             'common.tasks.delete_old_notifications', |             '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 urllib.error import HTTPError, URLError | ||||||
|  |  | ||||||
| from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend | from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend | ||||||
|  | from django.db.utils import OperationalError | ||||||
|  |  | ||||||
|  |  | ||||||
| class InvenTreeExchange(SimpleExchangeBackend): | class InvenTreeExchange(SimpleExchangeBackend): | ||||||
| @@ -32,3 +33,12 @@ class InvenTreeExchange(SimpleExchangeBackend): | |||||||
|         # catch connection errors |         # catch connection errors | ||||||
|         except (HTTPError, URLError): |         except (HTTPError, URLError): | ||||||
|             print('Encountered connection error while updating') |             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: | with open(cfg_filename, 'r') as cfg: | ||||||
|     CONFIG = yaml.safe_load(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 | # Default action is to run the system in Debug mode | ||||||
| # SECURITY WARNING: don't run with debug turned on in production! | # SECURITY WARNING: don't run with debug turned on in production! | ||||||
| DEBUG = _is_true(get_setting( | DEBUG = _is_true(get_setting( | ||||||
| @@ -130,6 +133,11 @@ LOGGING = { | |||||||
|         'handlers': ['console'], |         'handlers': ['console'], | ||||||
|         'level': log_level, |         'level': log_level, | ||||||
|     }, |     }, | ||||||
|  |     'filters': { | ||||||
|  |         'require_not_maintenance_mode_503': { | ||||||
|  |             '()': 'maintenance_mode.logging.RequireNotMaintenanceMode503', | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
| } | } | ||||||
|  |  | ||||||
| # Get a logger instance for this setup file | # 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") |     print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined") | ||||||
|     sys.exit(1) |     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) | # List of allowed hosts (default = allow all) | ||||||
| ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*']) | ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*']) | ||||||
|  |  | ||||||
| @@ -262,6 +276,9 @@ INSTALLED_APPS = [ | |||||||
|     'django.contrib.staticfiles', |     'django.contrib.staticfiles', | ||||||
|     'django.contrib.sites', |     'django.contrib.sites', | ||||||
|  |  | ||||||
|  |     # Maintenance | ||||||
|  |     'maintenance_mode', | ||||||
|  |  | ||||||
|     # InvenTree apps |     # InvenTree apps | ||||||
|     'build.apps.BuildConfig', |     'build.apps.BuildConfig', | ||||||
|     'common.apps.CommonConfig', |     'common.apps.CommonConfig', | ||||||
| @@ -272,6 +289,7 @@ INSTALLED_APPS = [ | |||||||
|     'report.apps.ReportConfig', |     'report.apps.ReportConfig', | ||||||
|     'stock.apps.StockConfig', |     'stock.apps.StockConfig', | ||||||
|     'users.apps.UsersConfig', |     'users.apps.UsersConfig', | ||||||
|  |     'plugin.apps.PluginAppConfig', | ||||||
|     'InvenTree.apps.InvenTreeConfig',       # InvenTree app runs last |     'InvenTree.apps.InvenTreeConfig',       # InvenTree app runs last | ||||||
|  |  | ||||||
|     # Third part add-ons |     # Third part add-ons | ||||||
| @@ -308,6 +326,7 @@ MIDDLEWARE = CONFIG.get('middleware', [ | |||||||
|     'django.contrib.messages.middleware.MessageMiddleware', |     'django.contrib.messages.middleware.MessageMiddleware', | ||||||
|     'django.middleware.clickjacking.XFrameOptionsMiddleware', |     'django.middleware.clickjacking.XFrameOptionsMiddleware', | ||||||
|     'InvenTree.middleware.AuthRequiredMiddleware', |     'InvenTree.middleware.AuthRequiredMiddleware', | ||||||
|  |     'maintenance_mode.middleware.MaintenanceModeMiddleware', | ||||||
| ]) | ]) | ||||||
|  |  | ||||||
| # Error reporting middleware | # Error reporting middleware | ||||||
| @@ -335,7 +354,6 @@ TEMPLATES = [ | |||||||
|             os.path.join(MEDIA_ROOT, 'report'), |             os.path.join(MEDIA_ROOT, 'report'), | ||||||
|             os.path.join(MEDIA_ROOT, 'label'), |             os.path.join(MEDIA_ROOT, 'label'), | ||||||
|         ], |         ], | ||||||
|         'APP_DIRS': True, |  | ||||||
|         'OPTIONS': { |         'OPTIONS': { | ||||||
|             'context_processors': [ |             'context_processors': [ | ||||||
|                 'django.template.context_processors.debug', |                 'django.template.context_processors.debug', | ||||||
| @@ -348,6 +366,13 @@ TEMPLATES = [ | |||||||
|                 'InvenTree.context.status_codes', |                 'InvenTree.context.status_codes', | ||||||
|                 'InvenTree.context.user_roles', |                 '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: | if _cache_host: | ||||||
|     # We are going to rely upon a possibly non-localhost for our cache, |     # 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 |     # 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 = { |     _cache_options = { | ||||||
|         "CLIENT_CLASS": "django_redis.client.DefaultClient", |         "CLIENT_CLASS": "django_redis.client.DefaultClient", | ||||||
|         "SOCKET_CONNECT_TIMEOUT": int(os.getenv("CACHE_CONNECT_TIMEOUT", "2")), |         "SOCKET_CONNECT_TIMEOUT": int(os.getenv("CACHE_CONNECT_TIMEOUT", "2")), | ||||||
| @@ -584,15 +609,9 @@ if _cache_host: | |||||||
|         }, |         }, | ||||||
|     } |     } | ||||||
|     CACHES = { |     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": { |         "default": { | ||||||
|             "BACKEND": "django_redis.cache.RedisCache", |             "BACKEND": "django_redis.cache.RedisCache", | ||||||
|             "LOCATION": f"redis://{_cache_host}:{_cache_port}/1", |             "LOCATION": f"redis://{_cache_host}:{_cache_port}/0", | ||||||
|             "OPTIONS": _cache_options, |             "OPTIONS": _cache_options, | ||||||
|         }, |         }, | ||||||
|     } |     } | ||||||
| @@ -876,3 +895,23 @@ MARKDOWNIFY_WHITELIST_ATTRS = [ | |||||||
| ] | ] | ||||||
|  |  | ||||||
| MARKDOWNIFY_BLEACH = False | 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%; |     width: 30%; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /* tracking table column size */ | ||||||
|  | #track-table .table-condensed th { | ||||||
|  |     inline-size: 30%; | ||||||
|  |     overflow-wrap: break-word; | ||||||
|  | } | ||||||
|  |  | ||||||
| .panel-heading .badge { | .panel-heading .badge { | ||||||
|     float: right; |     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(): | def update_exchange_rates(): | ||||||
|     """ |     """ | ||||||
|     Update currency exchange rates |     Update currency exchange rates | ||||||
|   | |||||||
| @@ -18,6 +18,7 @@ from part.urls import part_urls | |||||||
| from stock.urls import stock_urls | from stock.urls import stock_urls | ||||||
| from build.urls import build_urls | from build.urls import build_urls | ||||||
| from order.urls import order_urls | from order.urls import order_urls | ||||||
|  | from plugin.urls import get_plugin_urls | ||||||
|  |  | ||||||
| from barcodes.api import barcode_api_urls | from barcodes.api import barcode_api_urls | ||||||
| from common.api import common_api_urls, settings_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 order.api import order_api_urls | ||||||
| from label.api import label_api_urls | from label.api import label_api_urls | ||||||
| from report.api import report_api_urls | from report.api import report_api_urls | ||||||
|  | from plugin.api import plugin_api_urls | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.conf.urls.static import static | from django.conf.urls.static import static | ||||||
| @@ -62,6 +64,7 @@ apipatterns = [ | |||||||
|     url(r'^order/', include(order_api_urls)), |     url(r'^order/', include(order_api_urls)), | ||||||
|     url(r'^label/', include(label_api_urls)), |     url(r'^label/', include(label_api_urls)), | ||||||
|     url(r'^report/', include(report_api_urls)), |     url(r'^report/', include(report_api_urls)), | ||||||
|  |     url(r'^plugin/', include(plugin_api_urls)), | ||||||
|  |  | ||||||
|     # User URLs |     # User URLs | ||||||
|     url(r'^user/', include(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'^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'^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'^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'^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'), |     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/', include(apipatterns)), | ||||||
|     url(r'^api-doc/', include_docs_urls(title='InvenTree API')), |     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')), |     url(r'^markdownx/', include('markdownx.urls')), | ||||||
|  |  | ||||||
|     # DB user sessions |     # DB user sessions | ||||||
|   | |||||||
| @@ -12,7 +12,8 @@ from rest_framework.views import APIView | |||||||
| from stock.models import StockItem | from stock.models import StockItem | ||||||
| from stock.serializers import StockItemSerializer | 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): | class BarcodeScan(APIView): | ||||||
| @@ -187,21 +188,21 @@ class BarcodeAssign(APIView): | |||||||
|  |  | ||||||
|             if plugin.getStockItem() is not None: |             if plugin.getStockItem() is not None: | ||||||
|                 match_found = True |                 match_found = True | ||||||
|                 response['error'] = _('Barcode already matches StockItem object') |                 response['error'] = _('Barcode already matches Stock Item') | ||||||
|  |  | ||||||
|             if plugin.getStockLocation() is not None: |             if plugin.getStockLocation() is not None: | ||||||
|                 match_found = True |                 match_found = True | ||||||
|                 response['error'] = _('Barcode already matches StockLocation object') |                 response['error'] = _('Barcode already matches Stock Location') | ||||||
|  |  | ||||||
|             if plugin.getPart() is not None: |             if plugin.getPart() is not None: | ||||||
|                 match_found = True |                 match_found = True | ||||||
|                 response['error'] = _('Barcode already matches Part object') |                 response['error'] = _('Barcode already matches Part') | ||||||
|  |  | ||||||
|             if not match_found: |             if not match_found: | ||||||
|                 item = plugin.getStockItemByHash() |                 item = plugin.getStockItemByHash() | ||||||
|  |  | ||||||
|                 if item is not None: |                 if item is not None: | ||||||
|                     response['error'] = _('Barcode hash already matches StockItem object') |                     response['error'] = _('Barcode hash already matches Stock Item') | ||||||
|                     match_found = True |                     match_found = True | ||||||
|  |  | ||||||
|         else: |         else: | ||||||
| @@ -213,13 +214,13 @@ class BarcodeAssign(APIView): | |||||||
|             # Lookup stock item by hash |             # Lookup stock item by hash | ||||||
|             try: |             try: | ||||||
|                 item = StockItem.objects.get(uid=hash) |                 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 |                 match_found = True | ||||||
|             except StockItem.DoesNotExist: |             except StockItem.DoesNotExist: | ||||||
|                 pass |                 pass | ||||||
|  |  | ||||||
|         if not match_found: |         if not match_found: | ||||||
|             response['success'] = _('Barcode associated with StockItem') |             response['success'] = _('Barcode associated with Stock Item') | ||||||
|  |  | ||||||
|             # Save the barcode hash |             # Save the barcode hash | ||||||
|             item.uid = response['hash'] |             item.uid = response['hash'] | ||||||
|   | |||||||
| @@ -4,8 +4,6 @@ import string | |||||||
| import hashlib | import hashlib | ||||||
| import logging | import logging | ||||||
|  |  | ||||||
| from InvenTree import plugins as InvenTreePlugins |  | ||||||
| from barcodes import plugins as BarcodePlugins |  | ||||||
|  |  | ||||||
| from stock.models import StockItem | from stock.models import StockItem | ||||||
| from stock.serializers import StockItemSerializer, LocationSerializer | from stock.serializers import StockItemSerializer, LocationSerializer | ||||||
| @@ -139,24 +137,3 @@ class BarcodePlugin: | |||||||
|         Default implementation returns False |         Default implementation returns False | ||||||
|         """ |         """ | ||||||
|         return 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() |         build.save() | ||||||
|         count += 1 |         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): | 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 build.models import Build, BuildItem, get_next_build_number | ||||||
| from part.models import Part, BomItem | from part.models import Part, BomItem | ||||||
| from stock.models import StockItem | from stock.models import StockItem | ||||||
| from stock.tasks import delete_old_stock_items |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class BuildTest(TestCase): | class BuildTest(TestCase): | ||||||
| @@ -354,11 +353,6 @@ class BuildTest(TestCase): | |||||||
|         # the original BuildItem objects should have been deleted! |         # the original BuildItem objects should have been deleted! | ||||||
|         self.assertEqual(BuildItem.objects.count(), 0) |         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! |         # New stock items should have been created! | ||||||
|         self.assertEqual(StockItem.objects.count(), 7) |         self.assertEqual(StockItem.objects.count(), 7) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -962,6 +962,34 @@ class InvenTreeSetting(BaseInvenTreeSetting): | |||||||
|             'default': '', |             'default': '', | ||||||
|             'choices': settings_group_options |             '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: |     class Meta: | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ | |||||||
|   fields: |   fields: | ||||||
|     name: Zerg Corp |     name: Zerg Corp | ||||||
|     description: We eat the competition |     description: We eat the competition | ||||||
|  |     is_customer: False | ||||||
|  |  | ||||||
| - model: company.company | - model: company.company | ||||||
|   pk: 4 |   pk: 4 | ||||||
|   | |||||||
| @@ -277,13 +277,31 @@ class POLineItemFilter(rest_filters.FilterSet): | |||||||
|             'part' |             '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) |         value = str2bool(value) | ||||||
| @@ -293,7 +311,8 @@ class POLineItemFilter(rest_filters.FilterSet): | |||||||
|         if value: |         if value: | ||||||
|             queryset = queryset.filter(q) |             queryset = queryset.filter(q) | ||||||
|         else: |         else: | ||||||
|             queryset = queryset.exclude(q) |             # Only count "pending" orders | ||||||
|  |             queryset = queryset.exclude(q).filter(order__status__in=PurchaseOrderStatus.OPEN) | ||||||
|  |  | ||||||
|         return queryset |         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 -*- | # -*- coding: utf-8 -*- | ||||||
|  | """Class for ActionPlugin""" | ||||||
| 
 | 
 | ||||||
| import logging | import logging | ||||||
| 
 | 
 | ||||||
| import plugins.plugin as plugin | import plugin.plugin as plugin | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| logger = logging.getLogger("inventree") | logger = logging.getLogger("inventree") | ||||||
| @@ -42,7 +43,6 @@ class ActionPlugin(plugin.InvenTreePlugin): | |||||||
|         """ |         """ | ||||||
|         Override this method to perform the action! |         Override this method to perform the action! | ||||||
|         """ |         """ | ||||||
|         pass |  | ||||||
| 
 | 
 | ||||||
|     def get_result(self): |     def get_result(self): | ||||||
|         """ |         """ | ||||||
| @@ -68,25 +68,3 @@ class ActionPlugin(plugin.InvenTreePlugin): | |||||||
|             "result": self.get_result(), |             "result": self.get_result(), | ||||||
|             "info": self.get_info(), |             "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 -*- | # -*- coding: utf-8 -*- | ||||||
|  | """Base Class for InvenTree plugins""" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class InvenTreePlugin(): | class InvenTreePlugin(): | ||||||
|     """ |     """ | ||||||
|     Base class for a Barcode plugin |     Base class for a plugin | ||||||
|     """ |     """ | ||||||
| 
 | 
 | ||||||
|     # Override the plugin name for each concrete plugin instance |     # Override the plugin name for each concrete plugin instance | ||||||
|     PLUGIN_NAME = '' |     PLUGIN_NAME = '' | ||||||
| 
 | 
 | ||||||
|     def plugin_name(self): |     def plugin_name(self): | ||||||
|  |         """get plugin name""" | ||||||
|         return self.PLUGIN_NAME |         return self.PLUGIN_NAME | ||||||
| 
 | 
 | ||||||
|     def __init__(self): |     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) |         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): | class StockItemSerialize(generics.CreateAPIView): | ||||||
|     """ |     """ | ||||||
| @@ -174,6 +163,23 @@ class StockTransfer(StockAdjustView): | |||||||
|     serializer_class = StockSerializers.StockTransferSerializer |     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): | class StockLocationList(generics.ListCreateAPIView): | ||||||
|     """ |     """ | ||||||
|     API endpoint for list view of StockLocation objects: |     API endpoint for list view of StockLocation objects: | ||||||
| @@ -623,9 +629,6 @@ class StockList(generics.ListCreateAPIView): | |||||||
|  |  | ||||||
|         queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset) |         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 |         return queryset | ||||||
|  |  | ||||||
|     def filter_queryset(self, 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'^add/', StockAdd.as_view(), name='api-stock-add'), | ||||||
|     url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'), |     url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'), | ||||||
|     url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'), |     url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'), | ||||||
|  |     url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'), | ||||||
|  |  | ||||||
|     # StockItemAttachment API endpoints |     # StockItemAttachment API endpoints | ||||||
|     url(r'^attachment/', include([ |     url(r'^attachment/', include([ | ||||||
|   | |||||||
| @@ -21,20 +21,6 @@ from part.models import Part | |||||||
| from .models import StockLocation, StockItem, StockItemTracking | 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): | class ReturnStockItemForm(HelperForm): | ||||||
|     """ |     """ | ||||||
|     Form for manually returning a StockItem into stock |     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, |         belongs_to=None, | ||||||
|         customer=None, |         customer=None, | ||||||
|         is_building=False, |         is_building=False, | ||||||
|         status__in=StockStatus.AVAILABLE_CODES, |         status__in=StockStatus.AVAILABLE_CODES | ||||||
|         scheduled_for_deletion=False, |  | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # A query filter which can be used to filter StockItem objects which have expired |     # 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()) |     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): |     def update_serial_number(self): | ||||||
|         """ |         """ | ||||||
|         Update the 'serial_int' field, to be an integer representation of the serial number. |         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'), |                               help_text=_('Select Owner'), | ||||||
|                               related_name='stock_items') |                               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): |     def is_stale(self): | ||||||
|         """ |         """ | ||||||
|         Returns True if this Stock item is "stale". |         Returns True if this Stock item is "stale". | ||||||
| @@ -1327,7 +1315,7 @@ class StockItem(MPTTModel): | |||||||
|         self.quantity = quantity |         self.quantity = quantity | ||||||
|  |  | ||||||
|         if quantity == 0 and self.delete_on_deplete and self.can_delete(): |         if quantity == 0 and self.delete_on_deplete and self.can_delete(): | ||||||
|             self.mark_for_deletion() |             self.delete() | ||||||
|  |  | ||||||
|             return False |             return False | ||||||
|         else: |         else: | ||||||
|   | |||||||
| @@ -28,6 +28,8 @@ from .models import StockItemTestResult | |||||||
|  |  | ||||||
| import common.models | import common.models | ||||||
| from common.settings import currency_code_default, currency_code_mappings | from common.settings import currency_code_default, currency_code_mappings | ||||||
|  |  | ||||||
|  | import company.models | ||||||
| from company.serializers import SupplierPartSerializer | from company.serializers import SupplierPartSerializer | ||||||
|  |  | ||||||
| import InvenTree.helpers | 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): | class StockAdjustmentItemSerializer(serializers.Serializer): | ||||||
|     """ |     """ | ||||||
|     Serializer for a single StockItem within a stock adjument request. |     Serializer for a single StockItem within a stock adjument request. | ||||||
|   | |||||||
| @@ -1,35 +1,2 @@ | |||||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||||
| from __future__ import unicode_literals | 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 %} | {% if item.in_stock %} | ||||||
| $("#stock-assign-to-customer").click(function() { | $("#stock-assign-to-customer").click(function() { | ||||||
|     launchModalForm("{% url 'stock-item-assign' item.id %}", |  | ||||||
|         { |     inventreeGet('{% url "api-stock-detail" item.pk %}', {}, { | ||||||
|             reload: true, |         success: function(response) { | ||||||
|  |             assignStockToCustomer( | ||||||
|  |                 [response], | ||||||
|  |                 { | ||||||
|  |                     success: function() { | ||||||
|  |                         location.reload(); | ||||||
|  |                     }, | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
|     ); |     }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| $("#stock-move").click(function() { | $("#stock-move").click(function() { | ||||||
|   | |||||||
| @@ -16,9 +16,9 @@ from InvenTree.status_codes import StockStatus | |||||||
| from InvenTree.api_tester import InvenTreeAPITestCase | from InvenTree.api_tester import InvenTreeAPITestCase | ||||||
|  |  | ||||||
| from common.models import InvenTreeSetting | from common.models import InvenTreeSetting | ||||||
|  | import company.models | ||||||
| from .models import StockItem, StockLocation | import part.models | ||||||
| from .tasks import delete_old_stock_items | from stock.models import StockItem, StockLocation | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockAPITestCase(InvenTreeAPITestCase): | class StockAPITestCase(InvenTreeAPITestCase): | ||||||
| @@ -593,11 +593,7 @@ class StockItemDeletionTest(StockAPITestCase): | |||||||
|  |  | ||||||
|     def test_delete(self): |     def test_delete(self): | ||||||
|  |  | ||||||
|         # Check there are no stock items scheduled for deletion |         n = StockItem.objects.count() | ||||||
|         self.assertEqual( |  | ||||||
|             StockItem.objects.filter(scheduled_for_deletion=True).count(), |  | ||||||
|             0 |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         # Create and then delete a bunch of stock items |         # Create and then delete a bunch of stock items | ||||||
|         for idx in range(10): |         for idx in range(10): | ||||||
| @@ -615,9 +611,7 @@ class StockItemDeletionTest(StockAPITestCase): | |||||||
|  |  | ||||||
|             pk = response.data['pk'] |             pk = response.data['pk'] | ||||||
|  |  | ||||||
|             item = StockItem.objects.get(pk=pk) |             self.assertEqual(StockItem.objects.count(), n + 1) | ||||||
|  |  | ||||||
|             self.assertFalse(item.scheduled_for_deletion) |  | ||||||
|  |  | ||||||
|             # Request deletion via the API |             # Request deletion via the API | ||||||
|             self.delete( |             self.delete( | ||||||
| @@ -625,19 +619,7 @@ class StockItemDeletionTest(StockAPITestCase): | |||||||
|                 expected_code=204 |                 expected_code=204 | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|         # There should be 100x StockItem objects marked for deletion |         self.assertEqual(StockItem.objects.count(), n) | ||||||
|         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 |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class StockTestResultTest(StockAPITestCase): | class StockTestResultTest(StockAPITestCase): | ||||||
| @@ -751,3 +733,112 @@ class StockTestResultTest(StockAPITestCase): | |||||||
|  |  | ||||||
|             # Check that an attachment has been uploaded |             # Check that an attachment has been uploaded | ||||||
|             self.assertIsNotNone(response.data['attachment']) |             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) |         w1 = StockItem.objects.get(pk=100) | ||||||
|         w2 = StockItem.objects.get(pk=101) |         w2 = StockItem.objects.get(pk=101) | ||||||
|  |  | ||||||
|         self.assertFalse(w2.scheduled_for_deletion) |  | ||||||
|  |  | ||||||
|         # Take 25 units from w1 (there are only 10 in stock) |         # Take 25 units from w1 (there are only 10 in stock) | ||||||
|         w1.take_stock(30, None, notes='Took 30') |         w1.take_stock(30, None, notes='Took 30') | ||||||
|  |  | ||||||
| @@ -344,15 +342,6 @@ class StockTest(TestCase): | |||||||
|         # Take 25 units from w2 (will be deleted) |         # Take 25 units from w2 (will be deleted) | ||||||
|         w2.take_stock(30, None, notes='Took 30') |         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 |         # This StockItem should now have been deleted | ||||||
|         with self.assertRaises(StockItem.DoesNotExist): |         with self.assertRaises(StockItem.DoesNotExist): | ||||||
|             w2 = StockItem.objects.get(pk=101) |             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'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), | ||||||
|     url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), |     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'^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'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'), | ||||||
|     url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'), |     url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'), | ||||||
|  |  | ||||||
|   | |||||||
| @@ -294,39 +294,6 @@ class StockLocationQRCode(QRCodeView): | |||||||
|             return None |             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): | class StockItemReturnToStock(AjaxUpdateView): | ||||||
|     """ |     """ | ||||||
|     View for returning a stock item (which is assigned to a customer) to stock. |     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 i18n %} | ||||||
| {% load static %} | {% load static %} | ||||||
| {% load inventree_extras %} | {% load inventree_extras %} | ||||||
|  | {% load plugin_extras %} | ||||||
|  |  | ||||||
| {% block breadcrumb_list %} | {% block breadcrumb_list %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
| @@ -38,6 +39,14 @@ | |||||||
| {% include "InvenTree/settings/build.html" %} | {% include "InvenTree/settings/build.html" %} | ||||||
| {% include "InvenTree/settings/po.html" %} | {% include "InvenTree/settings/po.html" %} | ||||||
| {% include "InvenTree/settings/so.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 %} | {% endif %} | ||||||
|  |  | ||||||
| @@ -313,6 +322,10 @@ $("#import-part").click(function() { | |||||||
|     launchModalForm("{% url 'api-part-import' %}?reset", {}); |     launchModalForm("{% url 'api-part-import' %}?reset", {}); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | $("#install-plugin").click(function() { | ||||||
|  |     installPlugin(); | ||||||
|  | }); | ||||||
|  |  | ||||||
| enableSidebar('settings'); | enableSidebar('settings'); | ||||||
|  |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| {% load i18n %} | {% load i18n %} | ||||||
| {% load static %} | {% load static %} | ||||||
| {% load inventree_extras %} | {% load inventree_extras %} | ||||||
|  | {% load plugin_extras %} | ||||||
|  |  | ||||||
| {% trans "User Settings" as text %} | {% trans "User Settings" as text %} | ||||||
| {% include "sidebar_header.html" with text=text icon='fa-user' %} | {% include "sidebar_header.html" with text=text icon='fa-user' %} | ||||||
| @@ -46,4 +47,15 @@ | |||||||
| {% trans "Sales Orders" as text %} | {% trans "Sales Orders" as text %} | ||||||
| {% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %} | {% 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 %} | {% endif %} | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| {% load i18n %} | {% 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> |     <span class='fas fa-user-shield'></span> | ||||||
| </button> | </button> | ||||||
| @@ -178,6 +178,7 @@ | |||||||
| <script type='text/javascript' src="{% i18n_static 'part.js' %}"></script> | <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 'report.js' %}"></script> | ||||||
| <script type='text/javascript' src="{% i18n_static 'stock.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 'tables.js' %}"></script> | ||||||
| <script type='text/javascript' src="{% i18n_static 'table_filters.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 | /* exported | ||||||
|  |     assignStockToCustomer, | ||||||
|     createNewStockItem, |     createNewStockItem, | ||||||
|     createStockLocation, |     createStockLocation, | ||||||
|     duplicateStockItem, |     duplicateStockItem, | ||||||
| @@ -533,13 +534,166 @@ function exportStock(params={}) { | |||||||
|                 url += `&${key}=${params[key]}`; |                 url += `&${key}=${params[key]}`; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             console.log(url); |  | ||||||
|             location.href = 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 |  * Perform stock adjustments | ||||||
|  */ |  */ | ||||||
| @@ -777,7 +931,7 @@ function adjustStock(action, items, options={}) { | |||||||
|                 // Does the row exist in the form? |                 // Does the row exist in the form? | ||||||
|                 var row = $(opts.modal).find(`#stock_item_${pk}`); |                 var row = $(opts.modal).find(`#stock_item_${pk}`); | ||||||
|  |  | ||||||
|                 if (row) { |                 if (row.exists()) { | ||||||
|  |  | ||||||
|                     item_pk_values.push(pk); |                     item_pk_values.push(pk); | ||||||
|                      |                      | ||||||
| @@ -1098,7 +1252,7 @@ function locationDetail(row, showLink=true) { | |||||||
|         // StockItem has been assigned to a sales order |         // StockItem has been assigned to a sales order | ||||||
|         text = '{% trans "Assigned to Sales Order" %}'; |         text = '{% trans "Assigned to Sales Order" %}'; | ||||||
|         url = `/order/sales-order/${row.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; |         text = row.location_detail.pathstring; | ||||||
|         url = `/stock/location/${row.location}/`; |         url = `/stock/location/${row.location}/`; | ||||||
|     } else { |     } else { | ||||||
| @@ -1721,6 +1875,17 @@ function loadStockTable(table, options) { | |||||||
|         stockAdjustment('move'); |         stockAdjustment('move'); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     $('#multi-item-assign').click(function() { | ||||||
|  |  | ||||||
|  |         var items = $(table).bootstrapTable('getSelections'); | ||||||
|  |  | ||||||
|  |         assignStockToCustomer(items, { | ||||||
|  |             success: function() { | ||||||
|  |                 $(table).bootstrapTable('refresh'); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|     $('#multi-item-order').click(function() { |     $('#multi-item-order').click(function() { | ||||||
|         var selections = $(table).bootstrapTable('getSelections'); |         var selections = $(table).bootstrapTable('getSelections'); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -308,9 +308,17 @@ function getAvailableTableFilters(tableKey) { | |||||||
|     // Filters for PurchaseOrderLineItem table |     // Filters for PurchaseOrderLineItem table | ||||||
|     if (tableKey == 'purchaseorderlineitem') { |     if (tableKey == 'purchaseorderlineitem') { | ||||||
|         return { |         return { | ||||||
|             completed: { |             pending: { | ||||||
|                 type: 'bool', |                 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 static %} | ||||||
| {% load inventree_extras %} | {% load inventree_extras %} | ||||||
|  | {% load plugin_extras %} | ||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
| {% settings_value 'BARCODE_ENABLE' as barcodes %} | {% settings_value 'BARCODE_ENABLE' as barcodes %} | ||||||
| {% settings_value 'STICKY_HEADER' user=request.user as sticky %} | {% settings_value 'STICKY_HEADER' user=request.user as sticky %} | ||||||
|  | {% navigation_enabled as plugin_nav %} | ||||||
| {% inventree_demo_mode as demo %} | {% inventree_demo_mode as demo %} | ||||||
|  |  | ||||||
| <nav class="navbar {% if sticky %}fixed-top{% endif %} navbar-expand-lg navbar-light"> | <nav class="navbar {% if sticky %}fixed-top{% endif %} navbar-expand-lg navbar-light"> | ||||||
| @@ -57,6 +59,29 @@ | |||||||
|           </ul> |           </ul> | ||||||
|         </li> |         </li> | ||||||
|         {% endif %} |         {% 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> |       </ul> | ||||||
|     </div> |     </div> | ||||||
|     {% if demo %} |     {% 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-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-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-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> |                     <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 %} |                     {% endif %} | ||||||
|                     {% if roles.stock.delete %} |                     {% if roles.stock.delete %} | ||||||
|   | |||||||
| @@ -73,6 +73,7 @@ class RuleSet(models.Model): | |||||||
|             'socialaccount_socialaccount', |             'socialaccount_socialaccount', | ||||||
|             'socialaccount_socialapp', |             'socialaccount_socialapp', | ||||||
|             'socialaccount_socialtoken', |             'socialaccount_socialtoken', | ||||||
|  |             'plugin_pluginconfig' | ||||||
|         ], |         ], | ||||||
|         'part_category': [ |         'part_category': [ | ||||||
|             'part_partcategory', |             '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-filter==2.4.0            # Extended filtering options | ||||||
| django-formtools==2.3           # Form wizard tools | django-formtools==2.3           # Form wizard tools | ||||||
| django-import-export==2.5.0     # Data import / export for admin interface | 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-markdownify==0.8.0       # Markdown rendering | ||||||
| django-markdownx==3.0.1         # Markdown form fields | django-markdownx==3.0.1         # Markdown form fields | ||||||
| django-money==1.1               # Django app for currency management | 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 | djangorestframework==3.12.4     # DRF framework | ||||||
| flake8==3.8.3                   # PEP checking | flake8==3.8.3                   # PEP checking | ||||||
| gunicorn>=20.1.0                # Gunicorn web server | gunicorn>=20.1.0                # Gunicorn web server | ||||||
|  | importlib_metadata              # Backport for importlib.metadata | ||||||
| inventree                       # Install the latest version of the InvenTree API python library | inventree                       # Install the latest version of the InvenTree API python library | ||||||
| markdown==3.3.4                 # Force particular version of markdown | markdown==3.3.4                 # Force particular version of markdown | ||||||
| pep8-naming==0.11.1             # PEP naming convention extension | pep8-naming==0.11.1             # PEP naming convention extension | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user